@oh-my-pi/pi-ai 17.2.12 → 17.2.13

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 +19 -0
  2. package/dist/types/error/aws.d.ts +5 -1
  3. package/dist/types/providers/aws-credentials.d.ts +4 -3
  4. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  5. package/dist/types/providers/cursor-pi-args.d.ts +14 -0
  6. package/dist/types/providers/openai-shared.d.ts +9 -1
  7. package/dist/types/types.d.ts +7 -0
  8. package/dist/types/usage/cursor.d.ts +11 -0
  9. package/dist/types/utils/block-symbols.d.ts +12 -0
  10. package/package.json +5 -5
  11. package/src/dialect/owned-stream.ts +3 -0
  12. package/src/error/aws.ts +5 -1
  13. package/src/providers/amazon-bedrock.ts +38 -0
  14. package/src/providers/aws-credentials.ts +222 -29
  15. package/src/providers/cursor/exec-modern.ts +1 -0
  16. package/src/providers/cursor-pi-args.ts +22 -0
  17. package/src/providers/cursor.ts +81 -1
  18. package/src/providers/google-gemini-cli.ts +49 -15
  19. package/src/providers/google-shared.ts +7 -1
  20. package/src/providers/openai-codex/request-transformer.ts +38 -17
  21. package/src/providers/openai-codex-responses.ts +2 -3
  22. package/src/providers/openai-responses.ts +4 -0
  23. package/src/providers/openai-shared.ts +55 -1
  24. package/src/providers/pi-native-server.ts +1 -0
  25. package/src/providers/register-builtins.ts +18 -14
  26. package/src/registry/aws.ts +13 -6
  27. package/src/registry/oauth/callback-server.ts +93 -5
  28. package/src/stream.ts +1 -0
  29. package/src/types.ts +7 -0
  30. package/src/usage/cursor.ts +174 -42
  31. package/src/usage/kimi.ts +29 -5
  32. package/src/usage/openai-codex-reset.ts +2 -1
  33. package/src/usage/openai-codex.ts +2 -1
  34. package/src/usage/zai.ts +2 -1
  35. package/src/utils/aws-profile.ts +39 -1
  36. package/src/utils/block-symbols.ts +18 -0
  37. package/src/utils/leaked-thinking-stream.ts +3 -0
  38. package/src/utils/openrouter-headers.ts +3 -3
@@ -210,6 +210,7 @@ import {
210
210
  buildPiWriteError,
211
211
  buildPiWriteRejected,
212
212
  buildPiWriteResult,
213
+ omitUndefinedArgs,
213
214
  piEscapeRegexLiteral,
214
215
  piGrepSkip,
215
216
  piJoinPath,
@@ -223,6 +224,67 @@ import {
223
224
  export const CURSOR_API_URL = "https://api2.cursor.sh";
224
225
  export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b";
225
226
 
227
+ /**
228
+ * HTTP/1 connection-specific headers that HTTP/2 forbids. Node's `http2.request()`
229
+ * throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on these rather than dropping
230
+ * them, so a caller sending one would kill the request outright.
231
+ */
232
+ const HTTP2_FORBIDDEN_HEADERS = new Set([
233
+ "connection",
234
+ "keep-alive",
235
+ "proxy-connection",
236
+ "transfer-encoding",
237
+ "upgrade",
238
+ "http2-settings",
239
+ ]);
240
+
241
+ /**
242
+ * Header names the Cursor request sets for itself. A caller copy in ANY casing
243
+ * has to go: the spread below adds the fixed lower-case name regardless, and two
244
+ * spellings of one field are a duplicate rather than an override.
245
+ */
246
+ const CURSOR_RESERVED_HEADERS = new Set([
247
+ "content-type",
248
+ "connect-protocol-version",
249
+ "te",
250
+ "authorization",
251
+ "x-ghost-mode",
252
+ "x-cursor-client-version",
253
+ "x-cursor-client-type",
254
+ "x-request-id",
255
+ // Transport-owned even though this request never sets it: node's http2 client
256
+ // suppresses the `:authority` it derives from the URL when a plain `host`
257
+ // header is present, so a caller value here silently retargets the request at
258
+ // a different virtual host.
259
+ "host",
260
+ // The Connect body is streamed after the headers (initial frame, heartbeats,
261
+ // tool responses), so no caller-supplied length can describe it and an HTTP/2
262
+ // peer resets the stream once the body diverges.
263
+ "content-length",
264
+ ]);
265
+
266
+ /**
267
+ * Reduce caller-supplied headers to what this HTTP/2 request can legally carry.
268
+ *
269
+ * Everything is lower-cased, because HTTP/2 field names are lower-case and node
270
+ * compares them that way. A caller `Authorization` next to the fixed
271
+ * `authorization` does not lose to it, it DUPLICATES it, and node throws
272
+ * `ERR_HTTP2_HEADER_SINGLE_VALUE` before the request goes out. Same for a `TE`
273
+ * that is not `trailers`. Node throws on all three classes here rather than
274
+ * ignoring them, so a miss turns a harmless header into a dead request.
275
+ */
276
+ function sanitizeCursorCallerHeaders(headers: Record<string, string> | undefined): Record<string, string> {
277
+ const sanitized: Record<string, string> = {};
278
+ for (const [name, value] of Object.entries(headers ?? {})) {
279
+ const field = name.toLowerCase();
280
+ if (field.startsWith(":")) continue;
281
+ if (HTTP2_FORBIDDEN_HEADERS.has(field)) continue;
282
+ if (CURSOR_RESERVED_HEADERS.has(field)) continue;
283
+ sanitized[field] = value;
284
+ }
285
+ return sanitized;
286
+ }
287
+
226
288
  const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
227
289
 
228
290
  /**
@@ -545,7 +607,22 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
545
607
 
546
608
  const baseUrl = model.baseUrl || CURSOR_API_URL;
547
609
  const requestPath = "/agent.v1.AgentService/Run";
610
+ // Caller headers are additive, and are spread FIRST so the protocol
611
+ // framing, auth, and request id below always win. Cursor built this map
612
+ // from scratch and never read `options.headers`, so tracing/attribution
613
+ // headers set by a caller (or a `before_provider_headers` extension) were
614
+ // silently dropped here while working on other providers.
615
+ //
616
+ // Two classes are stripped because node's http2 client THROWS on them
617
+ // rather than ignoring them, which would turn a harmless header into a
618
+ // dead request: pseudo-headers, which belong to the transport, and the
619
+ // HTTP/1 connection-specific headers HTTP/2 forbids outright
620
+ // (ERR_HTTP2_INVALID_CONNECTION_HEADERS). `te` needs no filtering here —
621
+ // HTTP/2 allows it only as `trailers`, which is exactly what the fixed
622
+ // set below re-applies over anything a caller sent.
623
+ const callerHeaders = sanitizeCursorCallerHeaders(options?.headers);
548
624
  const requestHeaders = {
625
+ ...callerHeaders,
549
626
  ":method": "POST",
550
627
  ":path": requestPath,
551
628
  "content-type": "application/connect+proto",
@@ -3592,11 +3669,14 @@ export function synthesizeCursorExecToolCall(
3592
3669
  ): void {
3593
3670
  endCurrentTextBlock(output, stream, state);
3594
3671
  endCurrentThinkingBlock(output, stream, state);
3672
+ // Exec-frame translators often write `optional: value || undefined`. A
3673
+ // present `undefined` fails ArkType optional-field validation; drop those
3674
+ // keys so the transcript block matches what a model-native call would omit.
3595
3675
  const block: ToolCallState = {
3596
3676
  type: "toolCall",
3597
3677
  id: toolCallId,
3598
3678
  name: toolName,
3599
- arguments: args,
3679
+ arguments: omitUndefinedArgs(args),
3600
3680
  [kStreamingBlockIndex]: output.content.length,
3601
3681
  [kStreamingBlockKind]: "cursor-exec",
3602
3682
  [kCursorExecResolved]: true,
@@ -31,7 +31,7 @@ import { normalizeSystemPrompts } from "../utils";
31
31
  import { AssistantMessageEventStream } from "../utils/event-stream";
32
32
  import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../utils/google-validation";
33
33
  import type { RawHttpRequestDump } from "../utils/http-inspector";
34
- import { armPreResponseTimeout, getStreamFirstEventTimeoutMs } from "../utils/idle-iterator";
34
+ import { armPreResponseTimeout, getStreamFirstEventTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
35
35
  // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
36
36
  // the stream provider trusts the access token threaded through `options.apiKey`.
37
37
  import { normalizeSchemaForCCA } from "../utils/schema";
@@ -325,6 +325,9 @@ export {
325
325
  // Retry configuration
326
326
  const MAX_RETRIES = 3;
327
327
  const BASE_DELAY_MS = 1000;
328
+ const FLASH_FIRST_EVENT_TIMEOUT_MS = 60_000;
329
+ const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 300_000;
330
+ const FIRST_EVENT_TIMEOUT_ERROR = "Cloud Code Assist stream timed out while waiting for the first event";
328
331
  const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
329
332
  const CLAUDE_THINKING_BETA_HEADER = "interleaved-thinking-2025-05-14";
330
333
  const GOOGLE_GEMINI_REFRESH_SKEW_MS = 60_000;
@@ -616,12 +619,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
616
619
  headers: requestHeaders,
617
620
  };
618
621
 
619
- // Direct callers that skip `register-builtins` (which installs the
620
- // iterator-level watchdog) need a pre-response timer alongside
621
- // `timeout: false`; otherwise a stalled Cloud Code Assist proxy
622
- // would hang forever. Floor matches the lazy wrapper's 5min default.
622
+ // The provider owns the first-event watchdog so a silent successful
623
+ // response can fail over to the alternate Antigravity endpoint before
624
+ // anything user-visible has streamed. Flash should not inherit the
625
+ // five-minute allowance reserved for cold Pro reasoning starts.
623
626
  const firstEventTimeoutMs =
624
- options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(undefined, 300_000);
627
+ options?.streamFirstEventTimeoutMs ??
628
+ getStreamFirstEventTimeoutMs(
629
+ undefined,
630
+ model.id.includes("flash") ? FLASH_FIRST_EVENT_TIMEOUT_MS : DEFAULT_FIRST_EVENT_TIMEOUT_MS,
631
+ );
625
632
  const callerSignal = options?.signal;
626
633
  const toolNames = new Set(context.tools?.map(t => t.name) ?? []);
627
634
  const isFlashLeakModel = model.id.includes("flash");
@@ -653,7 +660,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
653
660
  sawFinishReason = false;
654
661
  };
655
662
 
656
- const streamResponse = async (activeResponse: Response): Promise<boolean> => {
663
+ const streamResponse = async (
664
+ activeResponse: Response,
665
+ ): Promise<{ meaningful: boolean; strippedPlanningLeak: boolean }> => {
657
666
  if (!activeResponse.body) {
658
667
  throw new AIError.ProviderResponseError("No response body", {
659
668
  provider: model.provider,
@@ -673,6 +682,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
673
682
  let isBuffering = false;
674
683
  let textBuffer = "";
675
684
  let bufferedTextSignature: string | undefined;
685
+ let strippedPlanningLeak = false;
676
686
 
677
687
  const endCurrentBlock = (): void => {
678
688
  if (!currentBlock) return;
@@ -755,11 +765,24 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
755
765
  }
756
766
  };
757
767
 
758
- for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
759
- activeResponse.body!,
760
- options?.signal,
761
- event => options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
762
- )) {
768
+ const responseAbortController = new AbortController();
769
+ const responseSignal = options?.signal
770
+ ? AbortSignal.any([options.signal, responseAbortController.signal])
771
+ : responseAbortController.signal;
772
+ const chunks = iterateWithIdleTimeout(
773
+ readSseJson<CloudCodeAssistResponseChunk>(activeResponse.body, responseSignal, event =>
774
+ options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
775
+ ),
776
+ {
777
+ firstItemTimeoutMs: firstEventTimeoutMs,
778
+ errorMessage: FIRST_EVENT_TIMEOUT_ERROR,
779
+ firstItemErrorMessage: FIRST_EVENT_TIMEOUT_ERROR,
780
+ onFirstItemTimeout: () =>
781
+ responseAbortController.abort(new AIError.StreamTimeoutError(FIRST_EVENT_TIMEOUT_ERROR)),
782
+ abortSignal: options?.signal,
783
+ },
784
+ );
785
+ for await (const chunk of chunks) {
763
786
  if (chunk.error) {
764
787
  const detail = chunk.error.message || chunk.error.status || "unknown error";
765
788
  const message = `Cloud Code Assist stream error: ${detail}`;
@@ -815,6 +838,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
815
838
  if (isBuffering) {
816
839
  const buffered = consumePlanningBuffer(textBuffer, toolNames);
817
840
  if (buffered.kind !== "incomplete") {
841
+ if (buffered.kind === "leak") strippedPlanningLeak = true;
818
842
  const visibleSignature = bufferedTextSignature;
819
843
  isBuffering = false;
820
844
  textBuffer = "";
@@ -895,6 +919,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
895
919
  const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
896
920
 
897
921
  if (buffered.kind !== "incomplete") {
922
+ if (buffered.kind === "leak") strippedPlanningLeak = true;
898
923
  feedVisibleText(buffered.visibleText, bufferedTextSignature);
899
924
  }
900
925
  bufferedTextSignature = undefined;
@@ -905,7 +930,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
905
930
  flushVisibleText(bufferedTextSignature);
906
931
  endCurrentBlock();
907
932
 
908
- return hasMeaningfulGoogleContent(output);
933
+ return {
934
+ meaningful: hasMeaningfulGoogleContent(output),
935
+ strippedPlanningLeak,
936
+ };
909
937
  };
910
938
 
911
939
  let receivedContent = false;
@@ -998,8 +1026,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
998
1026
  }
999
1027
 
1000
1028
  const streamed = await streamResponse(currentResponse);
1001
- if (output.stopReason !== "stop" || streamed) {
1002
- receivedContent = streamed;
1029
+ // Only accept an empty STOP as valid silence once every fallback
1030
+ // endpoint is exhausted: an earlier endpoint returning empty
1031
+ // successful streams must still fail over (Antigravity auto mode)
1032
+ // rather than be recorded as a real silent review.
1033
+ const acceptedSilence =
1034
+ options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint;
1035
+ if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) {
1036
+ receivedContent = streamed.meaningful || acceptedSilence;
1003
1037
  break;
1004
1038
  }
1005
1039
 
@@ -1031,7 +1031,13 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
1031
1031
  },
1032
1032
  });
1033
1033
 
1034
- if (output.stopReason !== "stop" || hasMeaningfulGoogleContent(output)) break;
1034
+ if (
1035
+ output.stopReason !== "stop" ||
1036
+ hasMeaningfulGoogleContent(output) ||
1037
+ options?.acceptEmptyResponse === true
1038
+ ) {
1039
+ break;
1040
+ }
1035
1041
  if (emptyAttempt >= MAX_EMPTY_STREAM_RETRIES) {
1036
1042
  throw new AIError.ProviderResponseError(
1037
1043
  `Google API returned an empty response (finishReason STOP with no content) after ${MAX_EMPTY_STREAM_RETRIES + 1} attempts`,
@@ -109,6 +109,22 @@ export function resolveCodexResponsesLite(
109
109
  return model.useResponsesLite === true;
110
110
  }
111
111
 
112
+ /**
113
+ * Whether to request `stream_options.reasoning_summary_delivery =
114
+ * "sequential_cutoff"` (codex-rs `concurrent_reasoning_summaries`), enabled by
115
+ * `PI_CODEX_CONCURRENT_SUMMARIES=1`.
116
+ *
117
+ * Off by default because the mode cancels summary sections still in flight when
118
+ * the reasoning item closes: measured over 12 interleaved turns it halved
119
+ * visible thinking (0.83 vs 1.67 summary parts, 37 vs 69 chars per turn) and
120
+ * produced no summary at all on 3 of 12 turns. codex-rs ships it disabled too
121
+ * (`Stage::UnderDevelopment`, `default_enabled: false`).
122
+ */
123
+ function concurrentSummariesEnabled(): boolean {
124
+ const env = $env.PI_CODEX_CONCURRENT_SUMMARIES?.trim().toLowerCase();
125
+ return env === "1" || env === "true";
126
+ }
127
+
112
128
  /**
113
129
  * Clamp a user-facing effort to the model's ladder, then remap to the wire
114
130
  * tier. User efforts map 1:1 onto wire tiers; the effort map only covers
@@ -145,12 +161,14 @@ function getReasoningConfig(
145
161
  const config: ReasoningConfig = {
146
162
  effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort),
147
163
  };
148
- if (
149
- options.reasoningSummary !== undefined &&
150
- options.reasoningSummary !== null &&
151
- supportsCodexReasoningSummary(model.id)
152
- ) {
153
- config.summary = options.reasoningSummary;
164
+ // The backend only emits reasoning summaries when `reasoning.summary` is
165
+ // present: omitting it yields zero `response.reasoning_summary_text.*`
166
+ // events (measured against gpt-5.5, gpt-5.6-sol and gpt-5.6-terra). So
167
+ // `undefined` means "default on" — matching `applyResponsesCompatPolicy`
168
+ // on the plain Responses path — and only an explicit `null` (the caller
169
+ // hiding thinking) opts out.
170
+ if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) {
171
+ config.summary = options.reasoningSummary ?? "auto";
154
172
  }
155
173
  return config;
156
174
  }
@@ -443,13 +461,15 @@ export async function transformRequestBody(
443
461
  ...body.reasoning,
444
462
  ...reasoningConfig,
445
463
  };
446
- // Responses Lite requires `all_turns`; the full transport leaves context to the server unless explicitly set.
447
- const context = responsesLite ? "all_turns" : options.reasoningContext;
448
- if (context !== undefined) {
449
- if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
464
+ // Lite requires `all_turns` even for opaque/codenamed model ids. Only explicit
465
+ // full-transport overrides are gated by the known model wire generation.
466
+ if (responsesLite) {
467
+ body.reasoning.context = "all_turns";
468
+ } else if (options.reasoningContext !== undefined) {
469
+ if (options.reasoningContext === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
450
470
  delete body.reasoning.context;
451
471
  } else {
452
- body.reasoning.context = context;
472
+ body.reasoning.context = options.reasoningContext;
453
473
  }
454
474
  }
455
475
  } else {
@@ -462,12 +482,13 @@ export async function transformRequestBody(
462
482
  body.reasoning = { ...body.reasoning, mode: model.reasoningMode };
463
483
  }
464
484
 
465
- // Concurrent reasoning summaries (codex-rs `concurrent_reasoning_summaries`
466
- // feature): `sequential_cutoff` lets the server stream output without
467
- // blocking on summary generation. Only meaningful when a summary is
468
- // requested; codex-rs additionally gates on its OpenAI provider check,
469
- // which is inherent here.
470
- if (body.reasoning?.summary !== undefined) {
485
+ // Concurrent reasoning summaries (codex-rs `concurrent_reasoning_summaries`):
486
+ // `sequential_cutoff` lets the server stream output without blocking on
487
+ // summary generation, delivering each completed section as an atomic
488
+ // `response.reasoning_summary_text.done`. Opt-in only — see
489
+ // {@link concurrentSummariesEnabled} for why. Requires a requested summary;
490
+ // codex-rs additionally gates on its OpenAI provider check, inherent here.
491
+ if (body.reasoning?.summary !== undefined && concurrentSummariesEnabled()) {
471
492
  body.stream_options = { reasoning_summary_delivery: "sequential_cutoff" };
472
493
  } else {
473
494
  delete body.stream_options;
@@ -1,4 +1,3 @@
1
- import * as os from "node:os";
2
1
  import { scheduler } from "node:timers/promises";
3
2
  import { type } from "@oh-my-pi/omptype";
4
3
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
@@ -19,8 +18,8 @@ import {
19
18
  parseStreamingJson,
20
19
  readSseJson,
21
20
  structuredCloneJSON,
21
+ USER_AGENT,
22
22
  } from "@oh-my-pi/pi-utils";
23
- import packageJson from "../../package.json" with { type: "json" };
24
23
  import * as AIError from "../error";
25
24
  import { getEnvApiKey, isOfficialCodexApiUrl } from "../stream";
26
25
  import type {
@@ -4250,7 +4249,7 @@ function createCodexHeaders(
4250
4249
  headers.set(OPENAI_HEADERS.BETA, betaHeader);
4251
4250
  headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
4252
4251
  headers.set(OPENAI_HEADERS.VERSION, codexClientVersion);
4253
- headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
4252
+ headers.set("User-Agent", USER_AGENT);
4254
4253
  if (sessionId) {
4255
4254
  headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
4256
4255
  headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
@@ -1113,6 +1113,10 @@ export function buildParams(
1113
1113
  filterReasoning: policy.reasoning.filterReasoningHistory,
1114
1114
  },
1115
1115
  includeThinkingSignatures: shouldReplayNativeHistory && !policy.reasoning.filterReasoningHistory,
1116
+ requiresReasoningReplayForAllTurns:
1117
+ policy.reasoning.enabled && policy.reasoning.requiresReasoningContentForAllAssistantTurns,
1118
+ requiresReasoningReplayForToolCalls:
1119
+ policy.reasoning.enabled && policy.reasoning.requiresReasoningContentForToolCalls,
1116
1120
  repairOrphanOutputs: true,
1117
1121
  });
1118
1122
 
@@ -1635,6 +1635,14 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
1635
1635
  repairOrphanOutputs?: boolean;
1636
1636
  /** Preserve assistant message item IDs from text signatures during fallback replay. */
1637
1637
  preserveAssistantMessageIds?: boolean;
1638
+ /**
1639
+ * Synthesize a reasoning item for every replayed assistant turn that carries
1640
+ * content but no reasoning item. Set for DeepSeek-family Responses targets
1641
+ * that reject a thinking-mode continuation lacking `reasoning_text`.
1642
+ */
1643
+ requiresReasoningReplayForAllTurns?: boolean;
1644
+ /** As {@link requiresReasoningReplayForAllTurns}, but only for turns that contain a tool call. */
1645
+ requiresReasoningReplayForToolCalls?: boolean;
1638
1646
  }
1639
1647
 
1640
1648
  /**
@@ -1861,6 +1869,8 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1861
1869
  supportsCustomToolCalls,
1862
1870
  customToolWireNameMap,
1863
1871
  computerCallIds,
1872
+ options.requiresReasoningReplayForAllTurns ?? false,
1873
+ options.requiresReasoningReplayForToolCalls ?? false,
1864
1874
  );
1865
1875
  const outputItems = suppressHiddenEmptyFallback
1866
1876
  ? sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(convertedOutputItems)
@@ -1914,6 +1924,8 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1914
1924
  supportsCustomToolCalls = true,
1915
1925
  customToolWireNameMap?: ReadonlyMap<string, string>,
1916
1926
  computerCallIds?: Set<string>,
1927
+ requiresReasoningReplayForAllTurns = false,
1928
+ requiresReasoningReplayForToolCalls = false,
1917
1929
  ): ResponseInput {
1918
1930
  const outputItems: ResponseInput = [];
1919
1931
  let unsignedTextBlocks = 0;
@@ -1925,14 +1937,36 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1925
1937
  );
1926
1938
  const isDifferentModel =
1927
1939
  assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api;
1940
+ // DeepSeek-family Responses targets (e.g. opencode-go) reject a thinking-mode
1941
+ // continuation whose replayed assistant turns carry no reasoning item: "The
1942
+ // reasoning_text in the thinking mode must be passed back to the API." After a
1943
+ // cross-model prewalk hand-off or a compaction that drops the native replay
1944
+ // payload, the block re-encode below demotes reasoning to text and emits no
1945
+ // reasoning item. Track reasoning emission so a placeholder can be synthesized,
1946
+ // mirroring the chat-completions `requiresReasoningContentForAllAssistantTurns`
1947
+ // empty-`reasoning_content` safety net.
1948
+ const requiresReasoningItem =
1949
+ assistantMsg.stopReason !== "error" &&
1950
+ (requiresReasoningReplayForAllTurns ||
1951
+ (requiresReasoningReplayForToolCalls && assistantMsg.content.some(block => block.type === "toolCall")));
1952
+ let reasoningItemEmitted = false;
1953
+ const carriedReasoningTexts: string[] = [];
1954
+ let synthesizedReasoningItemId: string | undefined;
1928
1955
 
1929
1956
  for (const block of assistantMsg.content) {
1930
1957
  if (block.type === "thinking" && assistantMsg.stopReason !== "error") {
1958
+ if (requiresReasoningItem) {
1959
+ if (block.itemId) synthesizedReasoningItemId ??= block.itemId;
1960
+ if (block.thinking.trim().length > 0) carriedReasoningTexts.push(block.thinking);
1961
+ }
1931
1962
  if (!includeThinkingSignatures) {
1932
1963
  continue;
1933
1964
  }
1934
1965
  const reasoningItem = parseResponseReasoningReplayItem(block.thinkingSignature);
1935
- if (reasoningItem) outputItems.push(reasoningItem);
1966
+ if (reasoningItem) {
1967
+ outputItems.push(reasoningItem);
1968
+ reasoningItemEmitted = true;
1969
+ }
1936
1970
  continue;
1937
1971
  }
1938
1972
 
@@ -2033,6 +2067,26 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
2033
2067
  });
2034
2068
  }
2035
2069
 
2070
+ if (requiresReasoningItem && !reasoningItemEmitted && outputItems.length > 0) {
2071
+ // Replay the demoted reasoning (already present in `content` as visible
2072
+ // text) as a structured reasoning item so the thinking-mode continuation
2073
+ // carries the `reasoning_text` the provider requires. The text may be empty
2074
+ // when the source turn was minted by another model and its reasoning is
2075
+ // already folded into the message text; the item's presence is what
2076
+ // satisfies the provider contract, mirroring the empty `reasoning_content`
2077
+ // placeholder used on the chat-completions path.
2078
+ const reasoningText = carriedReasoningTexts.join("\n");
2079
+ const reasoningId =
2080
+ synthesizedReasoningItemId ?? `rs_${Bun.hash(`${model.id}:${msgIndex}:${reasoningText}`).toString(36)}`;
2081
+ const reasoningItem: ResponseReasoningItem = {
2082
+ type: "reasoning",
2083
+ id: reasoningId,
2084
+ summary: [],
2085
+ content: [{ type: "reasoning_text", text: reasoningText }],
2086
+ };
2087
+ outputItems.unshift(reasoningItem);
2088
+ }
2089
+
2036
2090
  return outputItems;
2037
2091
  }
2038
2092
 
@@ -78,6 +78,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet<keyof SimpleStreamOptions> = new Set([
78
78
  "preferWebsockets",
79
79
  "openrouterVariant",
80
80
  "loopGuard",
81
+ "acceptEmptyResponse",
81
82
  ] as const satisfies readonly (keyof SimpleStreamOptions)[]);
82
83
 
83
84
  // ---------------------------------------------------------------------------
@@ -202,6 +202,11 @@ interface LazyStreamLimits {
202
202
  * stream timeouts. Keep the lazy loader from racing it with generic errors.
203
203
  */
204
204
  providerHandlesStreamTimeouts?: boolean;
205
+ /**
206
+ * The provider retries or fails over when no first event arrives, while the
207
+ * lazy wrapper continues to own steady-state idle detection.
208
+ */
209
+ providerHandlesFirstEventTimeouts?: boolean;
205
210
  /**
206
211
  * Apply OpenAI-family idle timeout precedence in the lazy wrapper. Used by
207
212
  * local backends whose users historically tune slow prompt-processing gaps
@@ -210,16 +215,13 @@ interface LazyStreamLimits {
210
215
  openAIIdleEnvFloorsFirstEvent?: boolean;
211
216
  }
212
217
  /**
213
- * Cloud Code Assist (google-gemini-cli / google-antigravity) routinely takes
214
- * longer than the global 100s default to emit its first SSE event when serving
215
- * the heavier Gemini 3.x Pro tiers at high thinking levels. Bump the first-event
216
- * floor to five minutes so callers stop seeing spurious "stream timed out while
217
- * waiting for the first event" aborts on legitimate cold reasoning starts.
218
- * The steady-state idle watchdog stays on the global default since the upstream
219
- * emits thinking tokens frequently once it gets going.
218
+ * Cloud Code Assist owns first-event detection because Antigravity can return
219
+ * successful headers and then never emit an SSE event. Keeping the watchdog in
220
+ * the provider lets it fail over before surfacing an error; the lazy wrapper
221
+ * still catches post-first-event stalls.
220
222
  */
221
223
  const GOOGLE_GEMINI_CLI_LAZY_STREAM_LIMITS: LazyStreamLimits = {
222
- defaultFirstEventTimeoutMs: 300_000,
224
+ providerHandlesFirstEventTimeouts: true,
223
225
  };
224
226
 
225
227
  const PROVIDER_HANDLED_STREAM_TIMEOUTS: LazyStreamLimits = {
@@ -241,6 +243,7 @@ function forwardStream<TApi extends Api>(
241
243
  (async () => {
242
244
  try {
243
245
  const providerHandlesStreamTimeouts = limits?.providerHandlesStreamTimeouts === true;
246
+ const providerHandlesFirstEventTimeouts = limits?.providerHandlesFirstEventTimeouts === true;
244
247
  // Per-model catalog compat can widen the fallback watchdog for hosts
245
248
  // with no keepalive events (e.g. Bedrock reasoning models that go
246
249
  // quiet for minutes mid-thinking, issue #4758). Caller options and
@@ -258,12 +261,13 @@ function forwardStream<TApi extends Api>(
258
261
  (limits?.openAIIdleEnvFloorsFirstEvent
259
262
  ? getOpenAIStreamIdleTimeoutMs(idleTimeoutFallbackMs)
260
263
  : getStreamIdleTimeoutMs(idleTimeoutFallbackMs)));
261
- const firstItemTimeoutMs = providerHandlesStreamTimeouts
262
- ? 0
263
- : (options.streamFirstEventTimeoutMs ??
264
- (limits?.openAIIdleEnvFloorsFirstEvent
265
- ? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs, limits.defaultFirstEventTimeoutMs)
266
- : getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs)));
264
+ const firstItemTimeoutMs =
265
+ providerHandlesStreamTimeouts || providerHandlesFirstEventTimeouts
266
+ ? 0
267
+ : (options.streamFirstEventTimeoutMs ??
268
+ (limits?.openAIIdleEnvFloorsFirstEvent
269
+ ? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs, limits.defaultFirstEventTimeoutMs)
270
+ : getStreamFirstEventTimeoutMs(idleTimeoutMs, limits?.defaultFirstEventTimeoutMs)));
267
271
  // Providers with a server-driven local tool bridge (e.g. the Cursor
268
272
  // exec channel) mark their stream busy while a local tool runs; the
269
273
  // watchdog must not read that silence as a provider stall (#4593).
@@ -13,14 +13,21 @@ export interface AwsBedrockProviderOptions extends Readonly<Record<string, unkno
13
13
  }
14
14
 
15
15
  function isEc2Host(): boolean {
16
- for (const candidate of [
17
- "/sys/hypervisor/uuid",
18
- "/sys/devices/virtual/dmi/id/product_uuid",
19
- "/sys/devices/virtual/dmi/id/board_asset_tag",
20
- ]) {
16
+ // Xen instances tag DMI/hypervisor UUIDs with an `ec2` prefix. Nitro instances
17
+ // (EKS, modern EC2) expose the instance id in board_asset_tag (`i-...`) and
18
+ // "Amazon EC2" in the DMI vendor fields; cover both so Nitro/EKS hosts aren't
19
+ // misread as non-EC2 (product_uuid is often mode 0400 and unreadable there).
20
+ const checks: Array<[path: string, matches: (value: string) => boolean]> = [
21
+ ["/sys/hypervisor/uuid", v => v.startsWith("ec2")],
22
+ ["/sys/devices/virtual/dmi/id/product_uuid", v => v.startsWith("ec2")],
23
+ ["/sys/devices/virtual/dmi/id/board_asset_tag", v => v.startsWith("ec2") || v.startsWith("i-")],
24
+ ["/sys/devices/virtual/dmi/id/sys_vendor", v => v.includes("amazon ec2")],
25
+ ["/sys/devices/virtual/dmi/id/bios_vendor", v => v.includes("amazon ec2")],
26
+ ];
27
+ for (const [candidate, matches] of checks) {
21
28
  try {
22
29
  const value = fs.readFileSync(candidate, "utf8").trim().toLowerCase();
23
- if (value.startsWith("ec2")) return true;
30
+ if (matches(value)) return true;
24
31
  } catch {
25
32
  // Missing/unreadable DMI metadata means this probe is inconclusive.
26
33
  }