@oh-my-pi/pi-ai 18.9.0 → 18.9.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/types/auth-gateway/dispatch.d.ts +5 -3
  3. package/dist/types/judgment/index.d.ts +1 -1
  4. package/dist/types/judgment/native.d.ts +80 -0
  5. package/dist/types/judgment/openai-decisions.d.ts +3 -0
  6. package/dist/types/judgment/systemone.d.ts +7 -0
  7. package/dist/types/judgment/types.d.ts +2 -2
  8. package/dist/types/judgment/wire.d.ts +36 -0
  9. package/dist/types/providers/transform-messages.d.ts +14 -0
  10. package/dist/types/registry/oauth/cloudflare-workers-ai.d.ts +3 -0
  11. package/dist/types/utils/retry-wait.d.ts +9 -0
  12. package/package.json +6 -6
  13. package/src/auth/sqlite-credential-store.ts +4 -4
  14. package/src/auth-gateway/dispatch.ts +7 -5
  15. package/src/auth-gateway/routes/systemone.ts +16 -12
  16. package/src/judgment/index.ts +1 -1
  17. package/src/judgment/native.ts +333 -0
  18. package/src/judgment/openai-decisions-predicate.md +9 -0
  19. package/src/judgment/openai-decisions.ts +129 -0
  20. package/src/judgment/systemone.ts +65 -0
  21. package/src/judgment/types.ts +2 -2
  22. package/src/judgment/wire.ts +42 -0
  23. package/src/providers/amazon-bedrock.ts +6 -5
  24. package/src/providers/anthropic.ts +82 -70
  25. package/src/providers/cursor.ts +2 -6
  26. package/src/providers/devin.ts +20 -2
  27. package/src/providers/openai-responses.ts +7 -8
  28. package/src/providers/systemone-server.ts +1 -1
  29. package/src/providers/transform-messages.ts +146 -68
  30. package/src/registry/cloudflare-ai-gateway.ts +2 -2
  31. package/src/registry/hooks/api-key.ts +1 -0
  32. package/src/registry/oauth/cloudflare-ai-gateway.ts +2 -2
  33. package/src/registry/oauth/cloudflare-workers-ai.ts +32 -0
  34. package/src/usage/claude.ts +2 -6
  35. package/src/utils/empty-completion-retry.ts +2 -3
  36. package/src/utils/retry-wait.ts +19 -0
  37. package/dist/types/judgment/typesafe.d.ts +0 -49
  38. package/src/judgment/typesafe.ts +0 -183
@@ -1,5 +1,4 @@
1
1
  import * as fs from "node:fs";
2
- import { scheduler } from "node:timers/promises";
3
2
  import * as tls from "node:tls";
4
3
  import { isAnthropicSigningProxyUrl, isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
5
4
  import { hostMatchesUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts";
@@ -20,6 +19,7 @@ import { NO_AUTH_SENTINEL } from "../auth-retry";
20
19
  import { renderDemotedThinking } from "../dialect/demotion";
21
20
  import * as AIError from "../error";
22
21
  import { parseToolCallArguments } from "../utils/tool-call-arguments";
22
+ import { waitForRetry } from "../utils/retry-wait";
23
23
  import { getEnvApiKey } from "../env-api-key";
24
24
  import { OUTPUT_FALLBACK_BUFFER } from "../stream";
25
25
  import type {
@@ -3582,11 +3582,7 @@ const streamAnthropicOnce = (
3582
3582
  attempt: fallbackCreditTransientRetries,
3583
3583
  },
3584
3584
  );
3585
- if (options?.providerRetryWait) {
3586
- await options.providerRetryWait(500, options.signal);
3587
- } else {
3588
- await scheduler.wait(500, { signal: options?.signal });
3589
- }
3585
+ await waitForRetry(options?.providerRetryWait, 500, options?.signal);
3590
3586
  resetStreamOutputState();
3591
3587
  continue;
3592
3588
  }
@@ -3853,11 +3849,7 @@ const streamAnthropicOnce = (
3853
3849
  attempt: slowWaitAttempts,
3854
3850
  });
3855
3851
  if (slowRetry.delayMs > 0) {
3856
- if (options?.providerRetryWait) {
3857
- await options.providerRetryWait(slowRetry.delayMs, options.signal);
3858
- } else {
3859
- await scheduler.wait(slowRetry.delayMs, { signal: options?.signal });
3860
- }
3852
+ await waitForRetry(options?.providerRetryWait, slowRetry.delayMs, options?.signal);
3861
3853
  }
3862
3854
  resetStreamOutputState();
3863
3855
  continue;
@@ -3923,11 +3915,7 @@ const streamAnthropicOnce = (
3923
3915
  throw streamFailure;
3924
3916
  }
3925
3917
  const delayMs = headerDelayMs !== undefined ? Math.max(headerDelayMs, backoffDelayMs) : backoffDelayMs;
3926
- if (options?.providerRetryWait) {
3927
- await options.providerRetryWait(delayMs, options.signal);
3928
- } else {
3929
- await scheduler.wait(delayMs, { signal: options?.signal });
3930
- }
3918
+ await waitForRetry(options?.providerRetryWait, delayMs, options?.signal);
3931
3919
  output.content.length = 0;
3932
3920
  output.model = model.id;
3933
3921
  output.responseId = undefined;
@@ -4421,6 +4409,17 @@ function applyCacheControlToLastBlock(blocks: ContentBlockParam[], cacheControl:
4421
4409
  return false;
4422
4410
  }
4423
4411
 
4412
+ /** True for a mid-conversation tool-control message (`role: "system"`, tool-change blocks only). */
4413
+ function isToolControlMessage(message: MessageParam): boolean {
4414
+ return (
4415
+ message.role === "system" &&
4416
+ typeof message.content !== "string" &&
4417
+ Array.isArray(message.content) &&
4418
+ message.content.length > 0 &&
4419
+ message.content.every(block => block.type === "tool_addition" || block.type === "tool_removal")
4420
+ );
4421
+ }
4422
+
4424
4423
  const ANTHROPIC_MAX_BREAKPOINTS = 4;
4425
4424
  const ANTHROPIC_DECIMATION_INTERVAL = 15;
4426
4425
 
@@ -4482,85 +4481,98 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4482
4481
  // the suffix after it. Turn-scoped `clear_at` messages are absent next
4483
4482
  // request, so they still truncate the decimation range (ordinals would
4484
4483
  // shift), but per-call marks no longer freeze the tail.
4485
- let stableMessageEnd = messageEnd;
4486
- for (let index = 0; index <= messageEnd; index++) {
4487
- const message = params.messages[index];
4488
- if (message && (message.clear_at === "next_user_message" || isPerCallContextMessage(message))) {
4489
- stableMessageEnd = index - 1;
4490
- break;
4491
- }
4492
- }
4493
-
4494
- // Decimation counts conversational turns, so it reads the provenance marker
4495
- // `convertAnthropicMessages` records rather than the wire role. A wire `user`
4496
- // can also be a serialized `developer` message, a tool_result run, or an
4497
- // interior `Continue.` pad, none of which advance the user turn ordinal.
4498
- const userIndices: number[] = [];
4499
- for (let index = 0; index <= stableMessageEnd; index++) {
4500
- const message = params.messages[index];
4501
- if (message && isConversationalUser(message)) {
4502
- userIndices.push(index);
4503
- }
4504
- }
4505
-
4506
- // Stable historical decimation checkpoint every 15 user turns (15th, 30th, 45th...)
4507
- const decimationIndices = userIndices.filter((_, ordinal) => (ordinal + 1) % ANTHROPIC_DECIMATION_INTERVAL === 0);
4508
-
4484
+ //
4485
+ // One backward walk over the message tail resolves everything the anchor
4486
+ // selection needs at once: the trailing candidates, the stable prefix end,
4487
+ // and the conversational-turn count under it. The first per-call/turn-scoped
4488
+ // message from the start is the last mark this walk sees (so `markIndex` ends
4489
+ // up as that boundary), and the turn counter — reset on every mark — ends up
4490
+ // counting exactly the conversational turns before the first mark (the range
4491
+ // the old forward scan covered). Nothing is materialized: the checkpoint
4492
+ // search below walks the same history backwards and stops as soon as the
4493
+ // breakpoint budget is spent.
4494
+ let markIndex = -1;
4495
+ let stableUserTurns = 0;
4509
4496
  // Collect up to 2 trailing candidates from the message tail, skipping
4510
4497
  // per-call messages, turn-scoped messages, and mid-conversation
4511
4498
  // tool-control messages. A per-call tail candidate is rebuilt next request
4512
4499
  // (fresh timestamps on appended probes, fresh redaction bytes), so a
4513
4500
  // breakpoint on it cannot match — it would spend the tail anchor on bytes
4514
- // that never repeat while the persisted history behind it goes uncached.
4501
+ // that never repeat while the persisted history behind it gets uncached.
4515
4502
  // Turn-scoped messages are absent next request for the same reason, and
4516
4503
  // tool controls reject cache_control outright. The walk starts at the
4517
4504
  // message tail (not the truncated prefix end) so the anchor advances every
4518
4505
  // turn; the sub-prefix candidate below covers the reusable region behind
4519
4506
  // a mark.
4520
4507
  const trailingCandidates: number[] = [];
4521
- for (let index = messageEnd; index >= 0 && trailingCandidates.length < 2; index--) {
4508
+ for (let index = messageEnd; index >= 0; index--) {
4522
4509
  const message = params.messages[index];
4523
- if (!message || message.clear_at === "next_user_message" || isPerCallContextMessage(message)) continue;
4524
- if (
4525
- message.role === "system" &&
4526
- typeof message.content !== "string" &&
4527
- Array.isArray(message.content) &&
4528
- message.content.length > 0 &&
4529
- message.content.every(block => block.type === "tool_addition" || block.type === "tool_removal")
4530
- ) {
4510
+ if (!message) continue;
4511
+ if (message.clear_at === "next_user_message" || isPerCallContextMessage(message)) {
4512
+ markIndex = index;
4513
+ stableUserTurns = 0;
4531
4514
  continue;
4532
4515
  }
4533
- trailingCandidates.push(index);
4534
- }
4535
- // Prioritize:
4516
+ if (trailingCandidates.length < 2 && !isToolControlMessage(message)) {
4517
+ trailingCandidates.push(index);
4518
+ }
4519
+ // Decimation counts conversational turns, so it reads the provenance marker
4520
+ // `convertAnthropicMessages` records rather than the wire role. A wire `user`
4521
+ // can also be a serialized `developer` message, a tool_result run, or an
4522
+ // interior `Continue.` pad, none of which advance the user turn ordinal.
4523
+ if (isConversationalUser(message)) stableUserTurns++;
4524
+ }
4525
+ const stableMessageEnd = markIndex === -1 ? messageEnd : markIndex - 1;
4526
+
4527
+ // Decimation checkpoints are generated newest-first on demand instead of
4528
+ // being materialized as index arrays: the newest sits `stableUserTurns %
4529
+ // ANTHROPIC_DECIMATION_INTERVAL` conversational turns back from the stable
4530
+ // prefix end and every 15th turn follows it, so the walk stops as soon as the
4531
+ // breakpoint budget is spent. Candidates repeat when a trailing or sub-prefix
4532
+ // anchor is already a checkpoint; re-decorating the same message is a no-op
4533
+ // (its block already carries cache_control), so the budget accounting is
4534
+ // unchanged.
4535
+ //
4536
+ // Priority order:
4536
4537
  // 1. Most recent trailing message
4537
4538
  // 2. Latest decimation checkpoints (newest first) to maintain stable long-context anchors
4538
4539
  // 3. Newest message at or before the first per-call/turn-scoped mark, so a
4539
4540
  // volatile interior message costs only its own re-billed bytes instead
4540
4541
  // of invalidating the whole reusable prefix behind it
4541
4542
  // 4. Second trailing message
4542
- const candidateIndices: number[] = [];
4543
- if (trailingCandidates.length > 0) {
4544
- candidateIndices.push(trailingCandidates[0]);
4545
- }
4546
- for (let i = decimationIndices.length - 1; i >= 0; i--) {
4547
- if (!candidateIndices.includes(decimationIndices[i])) {
4548
- candidateIndices.push(decimationIndices[i]);
4543
+ //
4544
+ // Count only successful block decorations toward the message budget so an uncacheable
4545
+ // block (such as a thinking-only assistant) does not silently consume a breakpoint.
4546
+ let appliedCount = 0;
4547
+ if (trailingCandidates.length > 0 && appliedCount < messageBudget) {
4548
+ const message = params.messages[trailingCandidates[0]];
4549
+ if (message && applyCacheControlToMessage(message, cacheControl)) {
4550
+ appliedCount++;
4549
4551
  }
4550
4552
  }
4551
- if (stableMessageEnd < messageEnd && stableMessageEnd >= 0 && !candidateIndices.includes(stableMessageEnd)) {
4552
- candidateIndices.push(stableMessageEnd);
4553
+ // Stable historical decimation checkpoint every 15 user turns (15th, 30th, 45th...)
4554
+ if (stableUserTurns >= ANTHROPIC_DECIMATION_INTERVAL) {
4555
+ let turnsSinceCheckpoint = stableUserTurns % ANTHROPIC_DECIMATION_INTERVAL;
4556
+ for (let index = stableMessageEnd; index >= 0 && appliedCount < messageBudget; index--) {
4557
+ const message = params.messages[index];
4558
+ if (!message || !isConversationalUser(message)) continue;
4559
+ if (turnsSinceCheckpoint > 0) {
4560
+ turnsSinceCheckpoint--;
4561
+ continue;
4562
+ }
4563
+ turnsSinceCheckpoint = ANTHROPIC_DECIMATION_INTERVAL - 1;
4564
+ if (applyCacheControlToMessage(message, cacheControl)) {
4565
+ appliedCount++;
4566
+ }
4567
+ }
4553
4568
  }
4554
- for (const index of trailingCandidates) {
4555
- if (!candidateIndices.includes(index)) {
4556
- candidateIndices.push(index);
4569
+ if (stableMessageEnd < messageEnd && stableMessageEnd >= 0 && appliedCount < messageBudget) {
4570
+ const message = params.messages[stableMessageEnd];
4571
+ if (message && applyCacheControlToMessage(message, cacheControl)) {
4572
+ appliedCount++;
4557
4573
  }
4558
4574
  }
4559
-
4560
- // Count only successful block decorations toward the message budget so an uncacheable
4561
- // block (such as a thinking-only assistant) does not silently consume a breakpoint.
4562
- let appliedCount = 0;
4563
- for (const index of candidateIndices) {
4575
+ for (const index of trailingCandidates) {
4564
4576
  if (appliedCount >= messageBudget) break;
4565
4577
  const message = params.messages[index];
4566
4578
  if (message && applyCacheControlToMessage(message, cacheControl)) {
@@ -2,7 +2,6 @@ import * as fs from "node:fs/promises";
2
2
  import http2 from "node:http2";
3
3
  import { cursorModelParameters } from "@oh-my-pi/pi-catalog/compat/behavior";
4
4
  import { isCursorMaxModeWireId } from "@oh-my-pi/pi-catalog/compat/collapse";
5
- import { scheduler } from "node:timers/promises";
6
5
  import { classifyModel, collapseVariantId } from "@oh-my-pi/pi-catalog/compat/taxonomy";
7
6
  import type {
8
7
  ConversationStep,
@@ -227,6 +226,7 @@ import { deterministicUuid } from "../utils/deterministic-id";
227
226
  import { AssistantMessageEventStream } from "../utils/event-stream";
228
227
  import { connectProxiedSocket, getProxyForUrl, wrapFetchForProxy } from "../utils/proxy";
229
228
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
229
+ import { waitForRetry } from "../utils/retry-wait";
230
230
  import { sanitizeSchemaForCursor, toolWireSchema } from "../utils/schema";
231
231
  import { formatConnectEndStreamError, hasRetryableCursorErrorDetail } from "./connect-error-detail";
232
232
  import { CONNECT_END_STREAM_FLAG, ConnectFrameDecoder, frameConnectMessage } from "./connect-frame";
@@ -1759,11 +1759,7 @@ function streamCursorWithWireMode(
1759
1759
  });
1760
1760
  let retryDelayCompleted = false;
1761
1761
  try {
1762
- if (options?.providerRetryWait) {
1763
- await options.providerRetryWait(delayMs, options.signal);
1764
- } else {
1765
- await scheduler.wait(delayMs, { signal: options?.signal });
1766
- }
1762
+ await waitForRetry(options?.providerRetryWait, delayMs, options?.signal);
1767
1763
  retryDelayCompleted = !options?.signal?.aborted;
1768
1764
  } catch (waitError) {
1769
1765
  error = waitError;
@@ -61,7 +61,7 @@ import {
61
61
  ConnectFrameDecoder,
62
62
  frameConnectMessage,
63
63
  } from "./connect-frame";
64
- import { transformMessages } from "./transform-messages";
64
+ import { isResponsesFamilyApi, responsesCallComponent, transformMessages } from "./transform-messages";
65
65
 
66
66
  /** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
67
67
  export const DEVIN_API_URL = DEVIN_DEFAULT_BASE_URL;
@@ -79,6 +79,13 @@ const CHAT_MESSAGE_PATH = "/exa.api_server_pb.ApiServerService/GetChatMessage";
79
79
  const DEVIN_ASSIGN_MODEL_PATH = "/exa.api_server_pb.ApiServerService/AssignModel";
80
80
  const DEVIN_AUTH_PATH = "/exa.auth_pb.AuthService/GetUserJwt";
81
81
 
82
+ /**
83
+ * Cascade forwards replayed tool-call ids to the backend serving the chat model;
84
+ * OpenAI-routed models reject ids past OpenAI's 40-char cap or outside
85
+ * `[a-zA-Z0-9_-]` with an opaque `invalid_argument` (#15234).
86
+ */
87
+ const DEVIN_TOOL_CALL_ID_MAX_LENGTH = 40;
88
+
82
89
  /**
83
90
  * Hard upper bound on a single Connect frame payload. The 4-byte length prefix
84
91
  * is otherwise attacker-controlled (up to `2**32 - 1`), so a malicious or buggy
@@ -230,7 +237,18 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
230
237
  apiKey: auth.apiKey,
231
238
  userJwt: auth.userJwt,
232
239
  cascadeId: options?.conversationId ?? options?.sessionId ?? crypto.randomUUID(),
233
- messages: transformMessages(context.messages, model),
240
+ // Foreign ids (cross-model calls and their results alike): Responses
241
+ // composites (`call_id|item_id`) keep their wire `call_id`; opaque ids
242
+ // from other APIs keep every segment. Both are then sanitized and capped.
243
+ messages: transformMessages(
244
+ context.messages,
245
+ model,
246
+ (id, _target, source) =>
247
+ (isResponsesFamilyApi(source.api) ? responsesCallComponent(id) : id)
248
+ .replace(/[^a-zA-Z0-9_-]/g, "_")
249
+ .slice(0, DEVIN_TOOL_CALL_ID_MAX_LENGTH),
250
+ DEVIN_TOOL_CALL_ID_MAX_LENGTH,
251
+ ),
234
252
  };
235
253
  // Router models (`adaptive`) are not valid chat model uids: the server
236
254
  // resolves them through AssignModel and expects the returned uid plus
@@ -1,4 +1,3 @@
1
- import { scheduler } from "node:timers/promises";
2
1
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
3
2
  import {
4
3
  $flag,
@@ -40,6 +39,7 @@ import {
40
39
  } from "../utils/idle-iterator";
41
40
  import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
42
41
  import { notifyProviderResponse } from "../utils/provider-response";
42
+ import { waitForRetry } from "../utils/retry-wait";
43
43
  import {
44
44
  adaptSchemaForStrict,
45
45
  findStrictToolSchemaViolation,
@@ -249,8 +249,7 @@ export async function pollOpenAIResponsesResultForCompletion(args: {
249
249
  if (attempt > 0) {
250
250
  if (Date.now() + OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS >= deadline) return undefined;
251
251
  try {
252
- if (args.wait) await args.wait(OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS, args.signal);
253
- else await scheduler.wait(OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS, { signal: args.signal });
252
+ await waitForRetry(args.wait, OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS, args.signal);
254
253
  } catch {
255
254
  return undefined;
256
255
  }
@@ -1268,11 +1267,11 @@ const streamOpenAIResponsesOnce = (
1268
1267
  firstTokenTime = undefined;
1269
1268
  nativeOutputItems.length = 0;
1270
1269
 
1271
- if (options?.providerRetryWait) {
1272
- await options.providerRetryWait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, options.signal);
1273
- } else {
1274
- await scheduler.wait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, { signal: options?.signal });
1275
- }
1270
+ await waitForRetry(
1271
+ options?.providerRetryWait,
1272
+ OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS,
1273
+ options?.signal,
1274
+ );
1276
1275
  if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
1277
1276
  openaiStream = await openResponsesStreamWithFallbacks();
1278
1277
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Server side of TypeSafe's System One wire format (`POST /v1/systemone`),
3
- * served by the auth-gateway so TypeSafe SDKs and omp's own `TypeSafeJudge`
3
+ * served by the auth-gateway so TypeSafe SDKs and omp's own `NativeJudge`
4
4
  * can point `TYPESAFE_BASE_URL` at the gateway and never hold the real key.
5
5
  *
6
6
  * Requests are validated only as far as routing needs — the `model` id and
@@ -43,7 +43,7 @@ export const MAX_TOOL_CALL_ID_LENGTH = 64;
43
43
  * ids verbatim as provider correlation tokens), so ONLY these origins may be
44
44
  * canonicalized to their `call_` component for pairing.
45
45
  */
46
- function isResponsesFamilyApi(api: Api | undefined): boolean {
46
+ export function isResponsesFamilyApi(api: Api | undefined): boolean {
47
47
  return api === "openai-responses" || api === "openai-codex-responses" || api === "azure-openai-responses";
48
48
  }
49
49
 
@@ -53,7 +53,7 @@ function isResponsesFamilyApi(api: Api | undefined): boolean {
53
53
  * index 0) keeps its full id so unrelated empty-half ids never collapse onto
54
54
  * one empty-string bucket.
55
55
  */
56
- function responsesCallComponent(id: string): string {
56
+ export function responsesCallComponent(id: string): string {
57
57
  const pipe = id.indexOf("|");
58
58
  return pipe <= 0 ? id : id.slice(0, pipe);
59
59
  }
@@ -82,19 +82,27 @@ interface ToolCallOriginScope {
82
82
  opaqueCompositeCallIds: ReadonlySet<string>;
83
83
  }
84
84
 
85
- function collectToolCallOriginScope(messages: readonly Message[]): ToolCallOriginScope {
86
- const responsesComponents = new Set<string>();
87
- const opaqueCompositeCallIds = new Set<string>();
88
- for (const msg of messages) {
89
- if (msg.role !== "assistant") continue;
90
- const responsesOrigin = isResponsesFamilyApi(msg.api);
91
- for (const block of msg.content) {
92
- if (block.type !== "toolCall") continue;
93
- if (responsesOrigin) responsesComponents.add(responsesCallComponent(block.id));
94
- else if (block.id.includes("|")) opaqueCompositeCallIds.add(block.id);
95
- }
85
+ /**
86
+ * Record one assistant turn's tool-call ids into the origin-scope sets.
87
+ *
88
+ * Called from the normalization map (`transformMessages`) in document order, on
89
+ * the SAME post-transform content that message emits, so the two sets cover
90
+ * every assistant turn exactly once and hold its post-normalization, pre-dedup
91
+ * ids (`_dupN` replacements minted later by dedup are never scope members).
92
+ * Both sets are only read via `.has`, so ordering is inert.
93
+ */
94
+ function recordToolCallOriginScope(
95
+ msg: AssistantMessage,
96
+ content: Readonly<AssistantMessage["content"]>,
97
+ responsesComponents: Set<string>,
98
+ opaqueCompositeCallIds: Set<string>,
99
+ ): void {
100
+ const responsesOrigin = isResponsesFamilyApi(msg.api);
101
+ for (const block of content) {
102
+ if (block.type !== "toolCall") continue;
103
+ if (responsesOrigin) responsesComponents.add(responsesCallComponent(block.id));
104
+ else if (block.id.includes("|")) opaqueCompositeCallIds.add(block.id);
96
105
  }
97
- return { responsesComponents, opaqueCompositeCallIds };
98
106
  }
99
107
 
100
108
  /**
@@ -158,16 +166,70 @@ function appendSegmentDuplicateSuffix(segment: string, suffix: string, maxLength
158
166
 
159
167
  type PendingToolResultRewrite = { replacementId: string } | undefined;
160
168
 
169
+ /** A real (non-synthetic) tool result filed at its position in the transformed transcript. */
170
+ interface IndexedToolResult {
171
+ index: number;
172
+ msg: ToolResultMessage;
173
+ consumed: boolean;
174
+ }
175
+
176
+ /**
177
+ * File a SURVIVING (post-dedup) tool result into the request-wide index.
178
+ *
179
+ * Called from the dedup pass, once per tool result message, at the position the
180
+ * message occupies in that pass's output array — so the entries and their order
181
+ * describe the transcript the request will actually carry.
182
+ * The caller passes the pairing key for the id that survived the pass (a `_dup`
183
+ * rewrite changes it), which is what the consumers downstream must pair against.
184
+ */
185
+ function indexToolResult(
186
+ msg: ToolResultMessage,
187
+ index: number,
188
+ key: string,
189
+ realToolResultsById: Map<string, IndexedToolResult[]>,
190
+ ): void {
191
+ const entry: IndexedToolResult = { index, msg, consumed: false };
192
+ const entries = realToolResultsById.get(key);
193
+ if (entries) entries.push(entry);
194
+ else realToolResultsById.set(key, [entry]);
195
+ }
196
+
197
+ /**
198
+ * Outputs of {@link deduplicateToolCallIds}.
199
+ *
200
+ * Both indexes describe the pass's OWN output — a `_dup` rewrite changes ids — so
201
+ * callers read them only after the pass completes, and no caller can hand in a
202
+ * pre-filled container.
203
+ */
204
+ interface DeduplicatedToolCallIds {
205
+ messages: Message[];
206
+ /** Every real tool result filed at its position, keyed by pairing key. */
207
+ realToolResultsById: Map<string, IndexedToolResult[]>;
208
+ /** Pairing key of every `tool_use` id the pass left standing. */
209
+ validToolUseIds: Set<string>;
210
+ }
211
+
161
212
  function deduplicateToolCallIds(
162
213
  messages: Message[],
163
214
  originScope: ToolCallOriginScope,
164
- maxToolCallIdLength = MAX_TOOL_CALL_ID_LENGTH,
165
- duplicateSuffixPrefix = "_dup",
166
- ): Message[] {
215
+ maxToolCallIdLength: number,
216
+ duplicateSuffixPrefix: string,
217
+ ): DeduplicatedToolCallIds {
167
218
  const seenToolCallIds = new Map<string, number>();
168
219
  const pendingToolResultRewrites = new Map<string, PendingToolResultRewrite[]>();
220
+ const realToolResultsById = new Map<string, IndexedToolResult[]>();
221
+ const validToolUseIds = new Set<string>();
169
222
 
170
- return messages.map(msg => {
223
+ const enqueueToolResultRewrite = (id: string, rewrite: PendingToolResultRewrite): void => {
224
+ const rewrites = pendingToolResultRewrites.get(id);
225
+ if (rewrites) {
226
+ rewrites.push(rewrite);
227
+ return;
228
+ }
229
+ pendingToolResultRewrites.set(id, [rewrite]);
230
+ };
231
+
232
+ const transformed = messages.map((msg, index) => {
171
233
  if (msg.role === "toolResult") {
172
234
  // Pair on the call_ component: a composite result id
173
235
  // (`call_X|fc_Y`) must find the rewrite enqueued under its assistant
@@ -176,29 +238,34 @@ function deduplicateToolCallIds(
176
238
  // result is dropped downstream.
177
239
  const key = toolCallPairingKey(msg.toolCallId, originScope);
178
240
  const rewrites = pendingToolResultRewrites.get(key);
179
- if (!rewrites || rewrites.length === 0) return msg;
241
+ if (!rewrites || rewrites.length === 0) {
242
+ indexToolResult(msg, index, key, realToolResultsById);
243
+ return msg;
244
+ }
180
245
 
181
246
  const rewrite = rewrites.shift();
182
247
  if (rewrites.length === 0) pendingToolResultRewrites.delete(key);
183
- if (rewrite) return { ...msg, toolCallId: rewrite.replacementId };
248
+ if (rewrite) {
249
+ const rewritten = { ...msg, toolCallId: rewrite.replacementId };
250
+ indexToolResult(
251
+ rewritten,
252
+ index,
253
+ toolCallPairingKey(rewritten.toolCallId, originScope),
254
+ realToolResultsById,
255
+ );
256
+ return rewritten;
257
+ }
258
+ indexToolResult(msg, index, key, realToolResultsById);
184
259
  return msg;
185
260
  }
186
261
 
187
262
  if (msg.role !== "assistant") return msg;
188
263
 
189
- const enqueueToolResultRewrite = (id: string, rewrite: PendingToolResultRewrite): void => {
190
- const rewrites = pendingToolResultRewrites.get(id);
191
- if (rewrites) {
192
- rewrites.push(rewrite);
193
- return;
194
- }
195
- pendingToolResultRewrites.set(id, [rewrite]);
196
- };
197
-
198
- // Ids this turn has already touched; used to scope the "drop carried-over
199
- // pending rewrites" semantics to the FIRST occurrence per turn so multiple
200
- // blocks of the same id within one turn still accumulate as duplicates.
201
- const idsTouchedInTurn = new Set<string>();
264
+ // Ids this turn has already touched; scopes the "drop carried-over pending
265
+ // rewrites" step to an id's FIRST occurrence per turn, so multiple blocks
266
+ // of the same id within one turn still accumulate as duplicates. Empty
267
+ // sets always miss `.has`, so turns with no tool call allocate nothing.
268
+ let idsTouchedInTurn: Set<string> | undefined;
202
269
  let contentChanged = false;
203
270
  const content = msg.content.map(block => {
204
271
  if (block.type !== "toolCall") return block;
@@ -217,9 +284,9 @@ function deduplicateToolCallIds(
217
284
  // "No result provided" for it, and the upcoming real result(id) must
218
285
  // route to one of THIS turn's calls. Without this guard the older
219
286
  // `_dup` id would steal the next result.
220
- if (!idsTouchedInTurn.has(blockKey)) {
287
+ if (!idsTouchedInTurn?.has(blockKey)) {
288
+ (idsTouchedInTurn ??= new Set()).add(blockKey);
221
289
  pendingToolResultRewrites.delete(blockKey);
222
- idsTouchedInTurn.add(blockKey);
223
290
  }
224
291
 
225
292
  const previousCount = seenToolCallIds.get(blockKey) ?? 0;
@@ -250,9 +317,20 @@ function deduplicateToolCallIds(
250
317
  return { ...block, id: replacementId };
251
318
  });
252
319
 
320
+ // Surviving `tool_use` pairing keys, read off the SAME post-dedup content the
321
+ // pass emits — a rewrite changes the id, so the keys must come from here and
322
+ // not from the input message. Recorded before the early-out because
323
+ // `!contentChanged` still returns element-identical content.
324
+ for (const block of content) {
325
+ if (block.type !== "toolCall") continue;
326
+ validToolUseIds.add(toolCallPairingKey(block.id, originScope));
327
+ }
328
+
253
329
  if (!contentChanged) return msg;
254
330
  return { ...msg, content };
255
331
  });
332
+
333
+ return { messages: transformed, realToolResultsById, validToolUseIds };
256
334
  }
257
335
 
258
336
  const MAX_TOOL_CALL_NAME_LENGTH = 128;
@@ -728,6 +806,11 @@ export function transformMessages<TApi extends Api>(
728
806
  }
729
807
  }
730
808
  const targetHasReasoningSlot = targetReplaysPlaintextReasoning(model, targetCompat);
809
+ // Origin-scope sets, accumulated by the normalization map itself (below) so
810
+ // the scope needs no second transcript walk. Only read via `.has`, so
811
+ // iteration order is inert.
812
+ const responsesComponents = new Set<string>();
813
+ const opaqueCompositeCallIds = new Set<string>();
731
814
  // First pass: transform messages (thinking blocks, tool call ID normalization)
732
815
  const normalizedMessages = messages.map((msg, index) => {
733
816
  // User and developer messages pass through unchanged
@@ -1123,6 +1206,13 @@ export function transformMessages<TApi extends Api>(
1123
1206
  transformedContent[transformedContent.length - 1] = { ...finalBlock, text: finalBlock.text.trimEnd() };
1124
1207
  }
1125
1208
 
1209
+ // Record this turn's tool-call ids into the origin scope. Origin scope
1210
+ // must be complete before dedup reads it: filled here off the SAME
1211
+ // post-transform content this map emits, in document order, and never
1212
+ // read inside the map — so by the time the dedup pass consults it the
1213
+ // sets hold every assistant id, never a partial build.
1214
+ recordToolCallOriginScope(assistantMsg, transformedContent, responsesComponents, opaqueCompositeCallIds);
1215
+
1126
1216
  return {
1127
1217
  ...assistantMsg,
1128
1218
  content: transformedContent,
@@ -1133,31 +1223,32 @@ export function transformMessages<TApi extends Api>(
1133
1223
  // Per-concrete-id origin classification — the only scope `toolCallPairingKey`
1134
1224
  // consults to decide whether a pipe-bearing id is a canonicalizable Responses
1135
1225
  // composite or an opaque Chat Completions token that pairs by raw equality.
1136
- const originScope = collectToolCallOriginScope(normalizedMessages);
1137
- const transformed = deduplicateToolCallIds(
1226
+ // The normalization map above fills both; naming the pair is all this does.
1227
+ const originScope: ToolCallOriginScope = { responsesComponents, opaqueCompositeCallIds };
1228
+ // All real tool results, keyed by pairing key, in document order. One id can
1229
+ // map to more than one result: compaction can fold an assistant `tool_use`
1230
+ // into a summary string while its `tool_result` survives, and a later turn may
1231
+ // reuse the id. The dedup pass files them at each result's position in its own
1232
+ // output array, so `takeRealToolResult` below pulls the earliest unconsumed
1233
+ // result positioned AFTER the call's assistant turn (an orphaned earlier
1234
+ // result is never pulled forward onto a later call).
1235
+ //
1236
+ // The set carries the `tool_use` pairing keys the dedup pass left standing.
1237
+ // Anthropic rejects `tool_result` blocks whose `tool_use_id` does not appear in
1238
+ // a prior `tool_use` block. After handoff/compaction folds an assistant turn
1239
+ // into a summary string, the user-side `toolResult` for that turn can survive
1240
+ // while the originating `tool_use` disappears — leaving an orphan that
1241
+ // triggers HTTP 400, which the second pass drops as unreplayable.
1242
+ const {
1243
+ messages: transformed,
1244
+ realToolResultsById,
1245
+ validToolUseIds,
1246
+ } = deduplicateToolCallIds(
1138
1247
  normalizedMessages,
1139
1248
  originScope,
1140
1249
  maxNormalizedToolCallIdLength,
1141
1250
  duplicateToolCallIdSuffixPrefix,
1142
1251
  );
1143
- // All real tool results, keyed by id, in document order. One id can map to
1144
- // more than one result: compaction can fold an assistant `tool_use` into a
1145
- // summary string while its `tool_result` survives, and a later turn may reuse
1146
- // the id. `takeRealToolResult` pulls the earliest unconsumed result positioned
1147
- // AFTER the call's assistant turn, so an orphaned earlier result is never
1148
- // pulled forward onto a later call (which would surface a prior turn's output).
1149
- type IndexedToolResult = { index: number; msg: ToolResultMessage; consumed: boolean };
1150
- const realToolResultsById = new Map<string, IndexedToolResult[]>();
1151
- for (let index = 0; index < transformed.length; index++) {
1152
- const msg = transformed[index];
1153
- if (msg.role === "toolResult") {
1154
- const entry: IndexedToolResult = { index, msg, consumed: false };
1155
- const key = toolCallPairingKey(msg.toolCallId, originScope);
1156
- const entries = realToolResultsById.get(key);
1157
- if (entries) entries.push(entry);
1158
- else realToolResultsById.set(key, [entry]);
1159
- }
1160
- }
1161
1252
  const takeRealToolResult = (id: string, afterIndex: number): ToolResultMessage | undefined => {
1162
1253
  const entries = realToolResultsById.get(toolCallPairingKey(id, originScope));
1163
1254
  if (!entries) return undefined;
@@ -1169,19 +1260,6 @@ export function transformMessages<TApi extends Api>(
1169
1260
  return undefined;
1170
1261
  };
1171
1262
 
1172
- // Anthropic rejects `tool_result` blocks whose `tool_use_id` does not appear in a prior
1173
- // `tool_use` block. After handoff/compaction folds an assistant turn into a summary
1174
- // string, the user-side `toolResult` for that turn can survive while the originating
1175
- // `tool_use` disappears — leaving an orphan that triggers HTTP 400. Track the set of
1176
- // `tool_use` ids that survive transformation so the second pass can drop orphans cleanly.
1177
- const validToolUseIds = new Set<string>();
1178
- for (const msg of transformed) {
1179
- if (msg.role !== "assistant") continue;
1180
- for (const block of msg.content) {
1181
- if (block.type === "toolCall") validToolUseIds.add(toolCallPairingKey(block.id, originScope));
1182
- }
1183
- }
1184
-
1185
1263
  // Second pass: ensure each surviving assistant tool call is immediately
1186
1264
  // followed by exactly one corresponding tool result.
1187
1265
  const result: Message[] = [];