@oh-my-pi/pi-ai 18.14.0 → 18.14.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 CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.14.1] - 2026-10-31
6
+
7
+ ### Added
8
+
9
+ - Added the `codexAccessPrograms` stream option and `configureCodexAccessPrograms()` process default to turn off the automatic Daybreak Blue `access_programs` request on Codex ([#17508](https://github.com/can1357/oh-my-pi/pull/17508) by [@H4vC](https://github.com/H4vC))
10
+ - Added `waitForSibling` to `RotateCredentialOptions`; set it to `false` to skip the short sibling-unblock wait in `limits.rotate()` while still blocking the failed credential ([#17439](https://github.com/can1357/oh-my-pi/pull/17439) by [@wolfiesch](https://github.com/wolfiesch))
11
+
12
+ ### Changed
13
+
14
+ - Cursor runs served through `omp auth-gateway` are limited to the client's own tools, all advertised as MCP; Cursor's built-in tools (file, shell, edit, todo, web fetch, SCM) are no longer offered on that path ([#17382](https://github.com/can1357/oh-my-pi/pull/17382) by [@eggpeat](https://github.com/eggpeat))
15
+
16
+ ### Fixed
17
+
18
+ - Fixed native judgment requests (TypeSafe, OpenRouter Decisions, OpenAI Decisions, Workers AI) sending credentials in their state unredacted while credential redaction was on ([#17443](https://github.com/can1357/oh-my-pi/pull/17443))
19
+ - Fixed every new Codex session, including each subagent, losing a response and retrying when the native turn lane refuses live steering; once refused, later sessions on the same account, endpoint, and model queue follow-ups for four hours ([#14158](https://github.com/can1357/oh-my-pi/issues/14158), [#17438](https://github.com/can1357/oh-my-pi/pull/17438) by [@wolfiesch](https://github.com/wolfiesch))
20
+ - Fixed Cursor models served through `omp auth-gateway` being told their file and shell tools were unavailable while the client ran the calls anyway, in Cursor's own argument shape (edits failed validation); each call now reaches the client as one of its own tools and the turn stops with `toolUse` until the result comes back ([#17382](https://github.com/can1357/oh-my-pi/pull/17382) by [@eggpeat](https://github.com/eggpeat))
21
+ - Fixed Cursor models served through `omp auth-gateway` being told each tool call was handed off, which left Composer issuing calls blind and answering their results with empty stops; the gateway now keeps the Cursor run open while the client runs a step's calls and answers them in-band with the client's results on its next request (up to 30 minutes; otherwise the turn is rebuilt from history as before) ([#17382](https://github.com/can1357/oh-my-pi/pull/17382) by [@eggpeat](https://github.com/eggpeat))
22
+ - Fixed Anthropic-compatible endpoints behind a relay opening a new connection for every turn: the response body is now drained for up to 2.5 s after `message_stop` instead of being cancelled, so the kept-alive connection is reused ([#17432](https://github.com/can1357/oh-my-pi/pull/17432) by [@flosrn](https://github.com/flosrn))
23
+
5
24
  ## [18.14.0] - 2026-10-29
6
25
 
7
26
  ### Breaking Changes
@@ -2300,27 +2319,4 @@
2300
2319
  - Enabled freeform tool patch support for Azure OpenAI and Codex models.
2301
2320
  - Fixed an issue where the `/usage show` command returned "No usage data available" when using a custom proxy base URL for Codex.
2302
2321
 
2303
- ## [16.2.2] - 2026-06-27
2304
-
2305
- ### Added
2306
-
2307
- - Added a comprehensive, public-facing error module exported via the "./error" path, featuring structured error classification, provider-specific HTTP error classes (e.g., Anthropic, OpenAI, Gemini), OAuth/Auth-specific errors, rate-limit utilities, and retryability predicates.
2308
-
2309
- ### Changed
2310
-
2311
- - Updated OpenAI Codex defaults to increase default text verbosity to medium, enable detailed reasoning summaries by default, and include all turns in the reasoning context by default.
2312
- - Updated the OpenAI Codex WebSocket transport to resolve its configuration (via PI_CODEX_WEBSOCKET_* environment variables) once at startup rather than re-parsing on every request.
2313
- - Enhanced cross-model reasoning recovery and preservation to render demoted reasoning in the target model's canonical inline thinking dialect (such as Gemini's thinking fence or standard think tags) to prevent leaking inert context or control tokens into history.
2314
- - Broadened the leaked-thinking stream healer to recover reasoning emitted in any dialect's canonical idiom (including Gemini, Gemma, Harmony, and scratchpads) and route them to thinking events instead of raw markup.
2315
- - Implemented automatic retry logic for detected thinking-loop stalls to improve response reliability.
2316
- - Hardened stateful delta chaining to ignore transient streaming bookkeeping symbols during structural equality checks, preventing unnecessary full-transcript replays.
2317
-
2318
- ### Fixed
2319
-
2320
- - Fixed preservation of OpenAI Responses assistant message phase values across auth-gateway parsing, streaming, and history replay, ensuring GPT-5.4/GPT-5.5 intermediate updates and final answers retain their original phase labels.
2321
-
2322
- ### Removed
2323
-
2324
- - Removed Pi dialect support and related serialization/parsing logic.
2325
-
2326
- Older entries are archived in [packages/ai/CHANGELOG.md@ef4bc35e85d8](https://github.com/can1357/oh-my-pi/blob/ef4bc35e85d88932d92b01ae7121c9f47208cf40/packages/ai/CHANGELOG.md).
2322
+ Older entries are archived in [packages/ai/CHANGELOG.md@cef4a452836c](https://github.com/can1357/oh-my-pi/blob/cef4a452836c7a8d547dd14470c34c9ed2dbb5f8/packages/ai/CHANGELOG.md).
@@ -63,7 +63,8 @@ export declare class RateLimits implements LimitsApi {
63
63
  * For usage-limit and account-policy failures with no free sibling, sleeps
64
64
  * until the earliest sibling unblocks when that is at most
65
65
  * {@link SIBLING_UNBLOCK_WAIT_MAX_MS} away, then reports `afterSiblingWait`.
66
- * Aborting `options.signal` during that wait rejects.
66
+ * Setting `options.waitForSibling` to false skips that wait without skipping
67
+ * the failed credential's block. Aborting `options.signal` during the wait rejects.
67
68
  */
68
69
  rotate(provider: string, sessionId: string | undefined, options?: RotateCredentialOptions): Promise<CredentialRotation>;
69
70
  }
@@ -698,6 +698,8 @@ export type RotateCredentialOptions = {
698
698
  apiKey?: string;
699
699
  credentialId?: number;
700
700
  signal?: AbortSignal;
701
+ /** Whether to wait for a briefly blocked sibling; defaults to true. The failed credential is blocked either way. */
702
+ waitForSibling?: boolean;
701
703
  };
702
704
  /**
703
705
  * Outcome of {@link LimitsApi.rotate}.
@@ -1275,7 +1277,7 @@ export interface LimitsApi {
1275
1277
  *
1276
1278
  * For usage-limit and account-policy failures with no free sibling, waits
1277
1279
  * (abortable via `options.signal`) when a sibling's block expires within a
1278
- * few seconds, then reports `afterSiblingWait`.
1280
+ * few seconds, then reports `afterSiblingWait`, unless `options.waitForSibling` is false.
1279
1281
  */
1280
1282
  rotate(provider: string, sessionId: string | undefined, options?: RotateCredentialOptions): Promise<CredentialRotation>;
1281
1283
  /** Invalidate a credential matching an API key after authentication failure. */
@@ -1,8 +1,8 @@
1
1
  import type { CursorRule, McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
2
2
  import { CURSOR_CLIENT_VERSION } from "@oh-my-pi/pi-catalog/wire/cursor";
3
- import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
3
+ import { type AgentServerMessage, type ConversationStateStructure, type ExecServerMessage } from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
4
4
  import { type JsonValue } from "@oh-my-pi/pi-catalog/discovery/protobuf";
5
- import type { AssistantMessage, Context, CursorExecHandlerResult, CursorExecHandlers, CursorExecPairing, CursorTodoSyncHandler, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage } from "../types.js";
5
+ import type { AssistantMessage, Context, CursorExecHandlerResult, CursorExecHandlers, CursorExecPairing, CursorMcpCall, CursorTodoSyncHandler, CursorToolResultHandler, Message, Model, StreamFunction, StreamOptions, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage } from "../types.js";
6
6
  import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingEnvelopeId, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols.js";
7
7
  import { AssistantMessageEventStream } from "../utils/event-stream.js";
8
8
  export declare const CURSOR_API_URL = "https://api2.cursor.sh";
@@ -12,7 +12,14 @@ export interface CursorOptions extends StreamOptions {
12
12
  conversationId?: string;
13
13
  execHandlers?: CursorExecHandlers;
14
14
  onToolResult?: CursorToolResultHandler;
15
- /** Treat unhandled MCP calls as accepted handoffs to an external executor. */
15
+ /**
16
+ * The caller's client runs every tool (auth-gateway). Cursor is limited to
17
+ * MCP calls, every context tool is advertised as one, and a turn that hands
18
+ * the client a call stops with `toolUse`. With a `providerSessionState` to
19
+ * park in, the run stays open for the next request to answer its calls
20
+ * in-band (`CursorParkedRun`); without one, each call is handed off and
21
+ * Cursor told its result comes with the next request.
22
+ */
16
23
  externalToolExecutor?: boolean;
17
24
  /** Wire model id selected after thinking-effort routing (`resolveWireModelId`). */
18
25
  wireModelId?: string;
@@ -76,6 +83,22 @@ export interface BlockState {
76
83
  openToolCalls: Map<string, ToolCallState>;
77
84
  /** MCP call IDs synthesized from exec frames before their redundant streamed block arrives. */
78
85
  resolvedMcpToolCallIds: Set<string>;
86
+ /**
87
+ * Calls a relayed run handed its client in an earlier response
88
+ * (`CursorParkedRun`). Each response fills its own message, so a streamed
89
+ * announcement that trails the handoff must not open the call again there.
90
+ * Optional so existing test harnesses stay valid.
91
+ */
92
+ handedOffToolCallIds?: Set<string>;
93
+ /**
94
+ * MCP calls a relayed run (`CursorParkedRun`) has seen announced but holds
95
+ * no exec frame for, by call id. A streaming client runs every call it is
96
+ * sent and a streamed event cannot be taken back, while a response may hand
97
+ * the client only held calls; so a call stays off the stream and out of the
98
+ * message until its exec frame is held (`streamUnheldExternalCall`).
99
+ * Set only for a relayed run.
100
+ */
101
+ unheldExternalCalls?: Map<string, ToolCallState>;
79
102
  /**
80
103
  * Native `editToolCall` (StrReplace) ids whose materialization `readArgs` /
81
104
  * `writeArgs` must stay raw and must not synthesize extra transcript blocks.
@@ -104,6 +127,12 @@ export interface BlockState {
104
127
  * matching result and every transcript rebuild strips it as dangling.
105
128
  */
106
129
  onToolResult?: CursorToolResultHandler;
130
+ /**
131
+ * Keep an external executor's MCP exec frame open instead of handing the
132
+ * call off: a relayed run answers it in-band once the client's next request
133
+ * brings the result (`CursorParkedRun`).
134
+ */
135
+ holdExternalCall?: (execMsg: ExecServerMessage, call: CursorMcpCall) => void;
107
136
  }
108
137
  export interface UsageState {
109
138
  sawTokenDelta: boolean;
@@ -240,7 +269,7 @@ export declare function processInteractionUpdate(update: any, output: AssistantM
240
269
  * history; later turns rely on {@link buildCursorRulesEntryJson}.
241
270
  */
242
271
  export declare function buildCursorRequestContextRules(systemPrompt: readonly string[] | undefined): CursorRule[];
243
- export declare function buildMcpToolDefinitions(tools: Tool[] | undefined, requiresCursorToolSchemaProjection?: boolean): McpToolDefinition[];
272
+ export declare function buildMcpToolDefinitions(tools: Tool[] | undefined, requiresCursorToolSchemaProjection?: boolean, externalToolExecutor?: boolean): McpToolDefinition[];
244
273
  /**
245
274
  * Build `ConversationStateStructure.rootPromptMessagesJson` blob IDs for the
246
275
  * system prompt plus prior conversation history, as JSON blobs matching
@@ -1,7 +1,30 @@
1
1
  import type { RequestPolicy } from "@oh-my-pi/pi-catalog/compat/types";
2
2
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
3
- import type { Context, Model, StreamOptions } from "../../types.js";
3
+ import type { Context, Message, Model, StreamOptions, Tool } from "../../types.js";
4
4
  import { AssistantMessageEventStream } from "../../utils/event-stream.js";
5
+ /**
6
+ * Apply Factory's replay provenance before canonical normalization: only
7
+ * signed, Google-origin thinking and Google-origin call signatures survive;
8
+ * everything else is DROPPED (never demoted to visible text). A text block
9
+ * keeps its signature only when this model signed it.
10
+ *
11
+ * Tool-call names are encoded to their wire form here, before
12
+ * `transformMessages` drops calls whose names no provider could declare: the
13
+ * proxy accepts any caller tool name once sanitized, so a declared
14
+ * `my.tool/name` call must survive replay.
15
+ *
16
+ * Only Google-origin turns are stamped as the target, so `transformMessages`
17
+ * keeps their surviving signatures verbatim instead of demoting/stripping them
18
+ * as cross-model. Their ids are plain in either stamp, so pairing is
19
+ * unaffected. Every other turn keeps its real provenance: tool-result pairing
20
+ * reads it (Responses `call_id|item_id` results pair only with a
21
+ * Responses-origin call), and with its reasoning already gone there is
22
+ * nothing left for a cross-model pass to demote.
23
+ */
24
+ export declare function adoptFactoryReplay(message: Message, model: Model<"factory-droid-agent">): Message;
25
+ export declare function toGeminiTools(tools: Tool[] | undefined): Array<{
26
+ functionDeclarations: unknown[];
27
+ }> | undefined;
5
28
  export interface FactoryDroidGeminiOptions extends StreamOptions {
6
29
  /** Base URL including the `/api/llm/g/v1` namespace. */
7
30
  baseUrl: string;
@@ -1,10 +1,18 @@
1
1
  import type { Model } from "../../types.js";
2
2
  import type { RequestBody } from "./request-transformer.js";
3
3
  /**
4
- * Request Daybreak Blue on `body` when `accountId` may use it on `model` and
5
- * no earlier rejection dropped it.
4
+ * Set the process-wide default for requests that leave `codexAccessPrograms`
5
+ * unset. Hosts call this from their settings so side calls that bypass a
6
+ * session stream function still follow it; session requests pass their own
7
+ * value, so concurrent sessions with different settings stay independent.
6
8
  */
7
- export declare function applyCodexAccessPrograms(body: RequestBody, model: Model<"openai-codex-responses">, accountId: string | undefined): void;
9
+ export declare function configureCodexAccessPrograms(enabled: boolean): void;
10
+ /**
11
+ * Request Daybreak Blue on `body` when `requested` (else the process default)
12
+ * allows it, `accountId` may use it on `model`, and no earlier rejection
13
+ * dropped it.
14
+ */
15
+ export declare function applyCodexAccessPrograms(body: RequestBody, model: Model<"openai-codex-responses">, accountId: string | undefined, requested: boolean | undefined): void;
8
16
  /**
9
17
  * Strip `access_programs` from `body` when `error` is the backend rejecting it,
10
18
  * remembering the drop for (account, model). Returns `true` when the caller
@@ -23,6 +23,8 @@ export interface CodexAcceptedSteer {
23
23
  export interface CodexSteerOutcome {
24
24
  responseId: string | undefined;
25
25
  accepted: CodexAcceptedSteer[];
26
+ /** A steer was submitted, whether or not the server accepted it. */
27
+ submitted: boolean;
26
28
  /**
27
29
  * A submission ended without an acknowledgement, so the server may or may
28
30
  * not hold it. The caller must drop the socket rather than chain from it.
@@ -372,6 +372,13 @@ export interface StreamOptions {
372
372
  * without the capability.
373
373
  */
374
374
  storeResponses?: boolean;
375
+ /**
376
+ * Request the Daybreak Blue cyber access program on Codex when the account
377
+ * is eligible (see `openai-codex/access-programs.ts`). `false` omits
378
+ * `access_programs`. Unset falls back to the host's
379
+ * `configureCodexAccessPrograms` default, else on. Ignored elsewhere.
380
+ */
381
+ codexAccessPrograms?: boolean;
375
382
  /**
376
383
  * Disable native reasoning when the caller supplies an external scratchpad.
377
384
  * OpenAI Responses emits `reasoning: { effort: "none" }`; Anthropic and
@@ -572,7 +579,7 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
572
579
  * A rejecting transformer is swallowed and the reserved payload stands in.
573
580
  */
574
581
  cursorOnToolResult?: CursorToolResultHandler;
575
- /** Cursor hands unhandled MCP calls to an external executor instead of reporting them as missing. */
582
+ /** The caller's client runs every tool: Cursor offers them all as MCP tools and hands each call off to it. */
576
583
  cursorExternalToolExecutor?: boolean;
577
584
  /**
578
585
  * Amazon Bedrock Guardrail settings forwarded through transports that do not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.14.0",
3
+ "version": "18.14.1",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.14.0",
159
- "@oh-my-pi/pi-catalog": "18.14.0",
160
- "@oh-my-pi/pi-natives": "18.14.0",
161
- "@oh-my-pi/pi-utils": "18.14.0",
162
- "@oh-my-pi/pi-wire": "18.14.0"
158
+ "@oh-my-pi/omptype": "18.14.1",
159
+ "@oh-my-pi/pi-catalog": "18.14.1",
160
+ "@oh-my-pi/pi-natives": "18.14.1",
161
+ "@oh-my-pi/pi-utils": "18.14.1",
162
+ "@oh-my-pi/pi-wire": "18.14.1"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -395,7 +395,8 @@ export class RateLimits implements LimitsApi {
395
395
  * For usage-limit and account-policy failures with no free sibling, sleeps
396
396
  * until the earliest sibling unblocks when that is at most
397
397
  * {@link SIBLING_UNBLOCK_WAIT_MAX_MS} away, then reports `afterSiblingWait`.
398
- * Aborting `options.signal` during that wait rejects.
398
+ * Setting `options.waitForSibling` to false skips that wait without skipping
399
+ * the failed credential's block. Aborting `options.signal` during the wait rejects.
399
400
  */
400
401
  async rotate(
401
402
  provider: string,
@@ -421,7 +422,7 @@ export class RateLimits implements LimitsApi {
421
422
  credentialId: options?.credentialId,
422
423
  signal: options?.signal,
423
424
  });
424
- return awaitSiblingUnblock(mark, options?.signal);
425
+ return awaitSiblingUnblock(mark, options?.signal, options?.waitForSibling);
425
426
  }
426
427
 
427
428
  const deniedModel = AIError.codexChatGPTAccountPolicyModel(error);
@@ -467,7 +468,7 @@ export class RateLimits implements LimitsApi {
467
468
  routing,
468
469
  false,
469
470
  );
470
- return awaitSiblingUnblock(mark, options?.signal);
471
+ return awaitSiblingUnblock(mark, options?.signal, options?.waitForSibling);
471
472
  }
472
473
 
473
474
  const providerKey = providerTypeKey(provider, sessionCredential.type);
@@ -533,9 +534,13 @@ export class RateLimits implements LimitsApi {
533
534
  * Turn a no-sibling mark into a rotation by sleeping out the soonest sibling
534
535
  * block when it expires within {@link SIBLING_UNBLOCK_WAIT_MAX_MS}.
535
536
  */
536
- async function awaitSiblingUnblock(mark: UsageLimitMarkResult, signal?: AbortSignal): Promise<CredentialRotation> {
537
+ async function awaitSiblingUnblock(
538
+ mark: UsageLimitMarkResult,
539
+ signal?: AbortSignal,
540
+ waitForSibling = true,
541
+ ): Promise<CredentialRotation> {
537
542
  if (mark.switched) return { switched: true };
538
- if (mark.retryAtMs === undefined) return { switched: false };
543
+ if (!waitForSibling || mark.retryAtMs === undefined) return { switched: false };
539
544
  const waitMs = mark.retryAtMs - Date.now() + SIBLING_UNBLOCK_SLACK_MS;
540
545
  if (waitMs > SIBLING_UNBLOCK_WAIT_MAX_MS) return { switched: false };
541
546
  if (waitMs > 0) await scheduler.wait(waitMs, { signal });
package/src/auth/types.ts CHANGED
@@ -778,6 +778,8 @@ export type RotateCredentialOptions = {
778
778
  apiKey?: string;
779
779
  credentialId?: number;
780
780
  signal?: AbortSignal;
781
+ /** Whether to wait for a briefly blocked sibling; defaults to true. The failed credential is blocked either way. */
782
+ waitForSibling?: boolean;
781
783
  };
782
784
 
783
785
  /**
@@ -1379,7 +1381,7 @@ export interface LimitsApi {
1379
1381
  *
1380
1382
  * For usage-limit and account-policy failures with no free sibling, waits
1381
1383
  * (abortable via `options.signal`) when a sibling's block expires within a
1382
- * few seconds, then reports `afterSiblingWait`.
1384
+ * few seconds, then reports `afterSiblingWait`, unless `options.waitForSibling` is false.
1383
1385
  */
1384
1386
  rotate(
1385
1387
  provider: string,
@@ -28,6 +28,7 @@ import { type ApiKey, withAuth } from "../auth-retry";
28
28
  import * as AIError from "../error";
29
29
  import { validateImageInput } from "../images/shared";
30
30
  import { withInferenceUserAgent } from "../providers/inference-headers";
31
+ import { redactSensitiveInObject } from "../providers/transform-messages";
31
32
  import type { ImageContent, Model, Usage } from "../types";
32
33
  import { getRetryAfterMsFromHeaders } from "../utils/retry-after";
33
34
  import { openaiDecisionsWire } from "./openai-decisions";
@@ -38,6 +39,7 @@ import {
38
39
  type JudgeOptions,
39
40
  type JudgmentRequest,
40
41
  type JudgmentResult,
42
+ type JudgmentState,
41
43
  type Questions,
42
44
  tokenUsage,
43
45
  } from "./types";
@@ -357,6 +359,9 @@ export class NativeJudge implements Judge {
357
359
  request: JudgmentRequest<Q>,
358
360
  options?: NativeJudgeCallOptions,
359
361
  ): Promise<JudgmentResult<Q>> {
362
+ // The state goes to the wire as is, past the message transforms that redact chat requests.
363
+ const redacted = redactSensitiveInObject(request.state);
364
+ if (redacted.changed) request = { ...request, state: redacted.result as JudgmentState };
360
365
  checkJudgmentImages(
361
366
  this.label,
362
367
  this.#wire,
@@ -89,7 +89,12 @@ import {
89
89
  import { withReplaySafeStreamRetry } from "../utils/empty-completion-retry";
90
90
  import { AssistantMessageEventStream } from "../utils/event-stream";
91
91
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
92
- import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
92
+ import {
93
+ getStreamFirstEventTimeoutMs,
94
+ getStreamIdleTimeoutMs,
95
+ iterateWithIdleTimeout,
96
+ iterateWithTerminalGrace,
97
+ } from "../utils/idle-iterator";
93
98
  import {
94
99
  ANTHROPIC_SLOW_USAGE_LIMIT,
95
100
  ANTHROPIC_USAGE_LIMIT_HEADER,
@@ -1115,6 +1120,16 @@ const PROVIDER_MAX_RETRIES = 10;
1115
1120
  */
1116
1121
  const PING_PROGRESS_MAX_IDLE_MULTIPLIER = 3;
1117
1122
 
1123
+ /**
1124
+ * How long to keep reading the HTTP body after `message_stop`. The turn is
1125
+ * complete at that frame, but cancelling a body whose terminal chunk has not
1126
+ * arrived yet makes Bun close the socket instead of returning it to the pool,
1127
+ * so every turn behind a relay that flushes `message_stop` separately pays a
1128
+ * fresh connection (TCP, TLS, slow start). The drain runs after the turn
1129
+ * completes; the window only elapses on hosts that hold the body open.
1130
+ */
1131
+ const ANTHROPIC_POST_STOP_GRACE_MS = 2_500;
1132
+
1118
1133
  /**
1119
1134
  * Log a malformed-stream-envelope anomaly without aborting the turn. The strict
1120
1135
  * parser would `throw new AnthropicStreamEnvelopeError(...)` here; we instead
@@ -2619,6 +2634,7 @@ const streamAnthropicOnce = (
2619
2634
  let sawMessageStart = false;
2620
2635
  let sawTerminalEnvelope = false;
2621
2636
  let sawMessageStop = false;
2637
+ let messageStopAtMs: number | undefined;
2622
2638
  // Set when a duplicate message_start splices a second envelope onto
2623
2639
  // the stream; closed indexes then refuse to reopen so replayed
2624
2640
  // content cannot duplicate (see content_block_start guard).
@@ -2676,10 +2692,20 @@ const streamAnthropicOnce = (
2676
2692
  return true;
2677
2693
  },
2678
2694
  });
2695
+ const drainRequestAbortController = activeAbortTracker.requestAbortController;
2696
+ const drainedAnthropicStream = iterateWithTerminalGrace(timedAnthropicStream, {
2697
+ finishedAtMs: () => messageStopAtMs,
2698
+ graceMs: ANTHROPIC_POST_STOP_GRACE_MS,
2699
+ // The idle-timeout generator is parked mid-`next()` when the window
2700
+ // closes; aborting the transport settles that read. A bare abort sets
2701
+ // no local reason, so the completed turn is not reported as an error.
2702
+ onGraceEnd: () => drainRequestAbortController.abort(),
2703
+ awaitDrainOnReturn: options?.waitForTerminalDrain,
2704
+ });
2679
2705
  const observedAnthropicStream =
2680
2706
  rawSseObserver && !recordsRawSseEvents
2681
- ? observeDecodedAnthropicEvents(timedAnthropicStream, rawSseObserver)
2682
- : timedAnthropicStream;
2707
+ ? observeDecodedAnthropicEvents(drainedAnthropicStream, rawSseObserver)
2708
+ : drainedAnthropicStream;
2683
2709
  for await (const event of observedAnthropicStream) {
2684
2710
  sawEvent = true;
2685
2711
 
@@ -3094,7 +3120,9 @@ const streamAnthropicOnce = (
3094
3120
  } else if (event.type === "message_stop") {
3095
3121
  sawTerminalEnvelope = true;
3096
3122
  sawMessageStop = true;
3097
- // The protocol is complete even if a broken keep-alive leaves the HTTP body open.
3123
+ // The protocol is complete even if a broken keep-alive leaves the HTTP
3124
+ // body open; the terminal-grace wrapper drains the rest in the background.
3125
+ messageStopAtMs = Date.now();
3098
3126
  break;
3099
3127
  }
3100
3128
  }
@@ -857,6 +857,10 @@ message StepCompletedUpdate {
857
857
  int64 step_duration_ms = 2;
858
858
  }
859
859
 
860
+ message ToolRequestsListedUpdate {
861
+ uint32 call_count = 1;
862
+ }
863
+
860
864
  message InteractionUpdate {
861
865
  oneof message {
862
866
  TextDeltaUpdate text_delta = 1;
@@ -876,6 +880,7 @@ message InteractionUpdate {
876
880
  TurnEndedUpdate turn_ended = 14;
877
881
  StepStartedUpdate step_started = 16;
878
882
  StepCompletedUpdate step_completed = 17;
883
+ ToolRequestsListedUpdate tool_requests_listed = 27;
879
884
  }
880
885
  }
881
886