@gajae-code/ai 0.12.0 → 0.12.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.
@@ -1,4 +1,4 @@
1
- import { $credentialEnv, $env } from "@gajae-code/utils";
1
+ import { $credentialEnv, $pickCredentialEnv } from "@gajae-code/utils";
2
2
  import type { Context, Model, StreamFunction } from "../types";
3
3
  import type { AssistantMessageEventStream } from "../utils/event-stream";
4
4
  import { getVertexAccessToken } from "./google-auth";
@@ -72,7 +72,7 @@ function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
72
72
  }
73
73
 
74
74
  function resolveProject(options?: GoogleVertexOptions): string {
75
- const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT;
75
+ const project = options?.project || $pickCredentialEnv("GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT");
76
76
  if (!project) {
77
77
  throw new Error(
78
78
  "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
@@ -84,10 +84,41 @@ function resolveProject(options?: GoogleVertexOptions): string {
84
84
  function resolveEndpointHost(location: string): string {
85
85
  return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
86
86
  }
87
+ /**
88
+ * Vertex location, from trusted environment sources only and constrained to a
89
+ * region label.
90
+ *
91
+ * The location is interpolated into the request **host**
92
+ * (`${location}-aiplatform.googleapis.com`) as well as the path, and the request
93
+ * carries `Authorization: Bearer <accessToken>`. A value containing `/`
94
+ * terminates the authority component, so `evil.example.com/` resolves to origin
95
+ * `https://evil.example.com` and the Google access token leaves Google entirely.
96
+ * `$env` merges the caller's `cwd/.env`, so this was reachable from repository
97
+ * content.
98
+ *
99
+ * Both halves are needed: trusted resolution keeps a repository from setting it,
100
+ * and the shape check keeps any source from turning a region into an authority.
101
+ */
102
+ const VERTEX_LOCATION_RE = /^[a-z0-9-]+$/;
103
+
104
+ function assertVertexLocation(location: string): string {
105
+ if (!VERTEX_LOCATION_RE.test(location)) {
106
+ throw new Error(
107
+ `Invalid Vertex AI location ${JSON.stringify(location)}. Expected a region label such as "us-central1" or "global".`,
108
+ );
109
+ }
110
+ return location;
111
+ }
112
+
87
113
  function resolveLocation(options?: GoogleVertexOptions): string {
88
- const location = options?.location || $env.GOOGLE_CLOUD_LOCATION;
114
+ const location = options?.location || $credentialEnv("GOOGLE_CLOUD_LOCATION");
89
115
  if (!location) {
90
116
  throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
91
117
  }
92
- return location;
118
+ return assertVertexLocation(location);
119
+ }
120
+
121
+ /** Test seam: the Vertex location as resolved from options plus trusted env. */
122
+ export function resolveVertexLocationForTest(options?: GoogleVertexOptions): string {
123
+ return resolveLocation(options);
93
124
  }
@@ -18,7 +18,7 @@ import { normalizeSystemPrompts } from "../utils";
18
18
  import { AssistantMessageEventStream } from "../utils/event-stream";
19
19
  import { transportFailureFacts } from "../utils/fallback-transport";
20
20
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
21
- import { parseStreamingJson } from "../utils/json-parse";
21
+ import { isCompleteJson, parseStreamingJson } from "../utils/json-parse";
22
22
  import { resolveRetryBudget } from "../utils/retry-budget";
23
23
  import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
24
24
  import {
@@ -26,6 +26,7 @@ import {
26
26
  markToolChoiceIncapability,
27
27
  resolveToolChoice,
28
28
  } from "../utils/tool-choice-capability";
29
+ import { flagTruncatedToolCalls } from "./openai-responses-shared";
29
30
  import { transformMessages } from "./transform-messages";
30
31
 
31
32
  export interface OllamaChatOptions extends StreamOptions {
@@ -357,8 +358,10 @@ function endToolCallBlock(stream: AssistantMessageEventStream, output: Assistant
357
358
  return;
358
359
  }
359
360
  const toolCall = block as InternalToolCallBlock;
360
- if (toolCall.partialJson) {
361
- toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
361
+ if (toolCall.partialJson !== undefined) {
362
+ if (toolCall.partialJson.trim()) {
363
+ toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
364
+ }
362
365
  delete toolCall.partialJson;
363
366
  }
364
367
  stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial: output });
@@ -393,6 +396,8 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
393
396
  let activeThinkingIndex: number | undefined;
394
397
  let activeTextIndex: number | undefined;
395
398
  const activeToolIndices = new Set<number>();
399
+ const unverifiableArgumentToolCallIds = new Set<string>();
400
+ let sawTerminalChunk = false;
396
401
  try {
397
402
  const apiKey = options.apiKey || getEnvApiKey(model.provider);
398
403
  if (!apiKey) {
@@ -537,6 +542,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
537
542
  for (const call of chunk.message.tool_calls) {
538
543
  const name = call.function?.name ?? "unknown_tool";
539
544
  const rawArgs = call.function?.arguments;
545
+ const unverifiableArguments = typeof rawArgs !== "string";
540
546
  const partialJson = typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs ?? {});
541
547
  const toolCall: InternalToolCallBlock = {
542
548
  type: "toolCall",
@@ -545,6 +551,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
545
551
  arguments: parseStreamingJson<Record<string, unknown>>(partialJson),
546
552
  partialJson,
547
553
  };
554
+ if (unverifiableArguments) unverifiableArgumentToolCallIds.add(toolCall.id);
548
555
  output.content.push(toolCall);
549
556
  const index = output.content.length - 1;
550
557
  activeToolIndices.add(index);
@@ -561,6 +568,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
561
568
  }
562
569
  }
563
570
  if (chunk.done) {
571
+ sawTerminalChunk = true;
564
572
  if (activeThinkingIndex !== undefined) {
565
573
  endThinkingBlock(stream, output, activeThinkingIndex);
566
574
  activeThinkingIndex = undefined;
@@ -569,16 +577,36 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
569
577
  endTextBlock(stream, output, activeTextIndex);
570
578
  activeTextIndex = undefined;
571
579
  }
580
+ output.stopReason = mapDoneReason(chunk.done_reason, output);
581
+ // Ollama still owns every partialJson buffer here; use the helper's
582
+ // finalized-call branch before endToolCallBlock deletes those buffers.
583
+ // Non-string arguments have no raw completion evidence, so a length stop
584
+ // must fail closed rather than trusting normalized or re-serialized values.
585
+ flagTruncatedToolCalls(
586
+ output,
587
+ output.stopReason,
588
+ block => !unverifiableArgumentToolCallIds.has(block.id),
589
+ );
590
+ if (chunk.done_reason === undefined) {
591
+ for (const block of output.content) {
592
+ if (block.type !== "toolCall") continue;
593
+ const partialJson = (block as InternalToolCallBlock).partialJson;
594
+ if (partialJson !== undefined && !isCompleteJson(partialJson)) block.incompleteArguments = true;
595
+ }
596
+ }
572
597
  for (const index of activeToolIndices) {
573
598
  endToolCallBlock(stream, output, index);
574
599
  }
575
600
  activeToolIndices.clear();
576
- output.stopReason = mapDoneReason(chunk.done_reason, output);
577
601
  output.usage.input = chunk.prompt_eval_count ?? 0;
578
602
  output.usage.output = chunk.eval_count ?? 0;
579
603
  output.usage.totalTokens = output.usage.input + output.usage.output;
604
+ break;
580
605
  }
581
606
  }
607
+ if (!sawTerminalChunk) {
608
+ throw new Error("Ollama stream ended before terminal done chunk");
609
+ }
582
610
  output.duration = Date.now() - startTime;
583
611
  if (firstTokenTime) {
584
612
  output.ttft = firstTokenTime - startTime;
@@ -48,9 +48,13 @@ import {
48
48
  sanitizeOpenAIResponsesHistoryItemsForReplay,
49
49
  } from "../utils";
50
50
  import { AssistantMessageEventStream } from "../utils/event-stream";
51
- import { transportFailureFacts } from "../utils/fallback-transport";
51
+ import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE, transportFailureFacts } from "../utils/fallback-transport";
52
52
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
53
- import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
53
+ import {
54
+ getOpenAIStreamIdleTimeoutMs,
55
+ getStreamFirstEventTimeoutMs,
56
+ iterateWithIdleTimeout,
57
+ } from "../utils/idle-iterator";
54
58
  import { parseStreamingJson } from "../utils/json-parse";
55
59
  import { resolveRetryBudget } from "../utils/retry-budget";
56
60
  import {
@@ -103,7 +107,6 @@ const CODEX_MAX_RETRIES = 5;
103
107
  const CODEX_RETRY_DELAY_MS = 500;
104
108
  const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
105
109
  const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300000;
106
- const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = 15000;
107
110
  const CODEX_WEBSOCKET_RETRY_BUDGET = CODEX_MAX_RETRIES;
108
111
  const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
109
112
  const CODEX_PREVIOUS_RESPONSE_STALE_CODES = new Set(["previous_response_not_found", "codex_previous_response_stale"]);
@@ -274,6 +277,7 @@ async function retryCodexInitialTransportWithoutToolChoice(
274
277
 
275
278
  interface CodexRequestSetup {
276
279
  requestSignal: AbortSignal;
280
+ firstEventTimeoutMs: number | undefined;
277
281
  wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
278
282
  requestAbortController: AbortController;
279
283
  }
@@ -355,16 +359,6 @@ function getCodexWebSocketIdleTimeoutMs(overrideMs?: number): number {
355
359
  );
356
360
  }
357
361
 
358
- function getCodexWebSocketFirstEventTimeoutMs(idleTimeoutMs: number, overrideMs?: number): number {
359
- return (
360
- overrideMs ??
361
- parseCodexPositiveInteger(
362
- $env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS,
363
- Math.min(CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS, idleTimeoutMs),
364
- )
365
- );
366
- }
367
-
368
362
  function createCodexProviderSessionState(): CodexProviderSessionState {
369
363
  const state: CodexProviderSessionState = {
370
364
  webSocketSessions: new Map(),
@@ -391,8 +385,12 @@ function getCodexProviderSessionState(
391
385
  return created;
392
386
  }
393
387
 
394
- function createCodexWebSocketTransportError(message: string): Error {
395
- return new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`);
388
+ function createCodexWebSocketTransportError(message: string, providerCode?: string): Error & { providerCode?: string } {
389
+ const error = new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`) as Error & {
390
+ providerCode?: string;
391
+ };
392
+ error.providerCode = providerCode;
393
+ return error;
396
394
  }
397
395
 
398
396
  function isCodexWebSocketFatalError(error: Error): boolean {
@@ -405,6 +403,13 @@ function isCodexWebSocketTransportError(error: unknown): boolean {
405
403
  return error.message.startsWith(CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX);
406
404
  }
407
405
 
406
+ function isCodexFirstEventTimeout(error: unknown): boolean {
407
+ return (
408
+ error instanceof Error &&
409
+ (error as { providerCode?: unknown }).providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
410
+ );
411
+ }
412
+
408
413
  function isCodexWebSocketRetryableStreamError(error: unknown): boolean {
409
414
  if (!(error instanceof Error) || !isCodexWebSocketTransportError(error)) return false;
410
415
  const message = error.message.toLowerCase();
@@ -607,17 +612,22 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
607
612
  const requestSignal = options?.signal
608
613
  ? AbortSignal.any([options.signal, requestAbortController.signal])
609
614
  : requestAbortController.signal;
615
+ const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
616
+ const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
610
617
  const wrapCodexSseStream = (
611
618
  source: AsyncGenerator<Record<string, unknown>>,
612
619
  ): AsyncGenerator<Record<string, unknown>> =>
613
620
  iterateWithIdleTimeout(source, {
614
- idleTimeoutMs: options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(),
621
+ idleTimeoutMs,
622
+ firstItemTimeoutMs: firstEventTimeoutMs,
623
+ firstItemErrorMessage: "OpenAI Codex SSE stream timed out while waiting for the first event",
615
624
  errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
616
625
  onIdle: () => requestAbortController.abort(),
626
+ onFirstItemTimeout: () => requestAbortController.abort(),
617
627
  abortSignal: options?.signal,
618
628
  isProgressItem: isCodexStreamProgressEvent,
619
629
  });
620
- return { requestAbortController, requestSignal, wrapCodexSseStream };
630
+ return { requestAbortController, requestSignal, firstEventTimeoutMs, wrapCodexSseStream };
621
631
  }
622
632
 
623
633
  async function buildCodexRequestContext(
@@ -838,6 +848,7 @@ async function openCodexWebSocketTransport(
838
848
  websocketState,
839
849
  requestSetup.requestSignal,
840
850
  options,
851
+ requestSetup.firstEventTimeoutMs,
841
852
  );
842
853
  return { eventStream, requestBodyForState, transport: "websocket" };
843
854
  }
@@ -1478,7 +1489,7 @@ async function recoverCodexStreamError(
1478
1489
  runtime: CodexStreamRuntime,
1479
1490
  error: unknown,
1480
1491
  ): Promise<boolean> {
1481
- if (context.options?.fallbackManaged) return false;
1492
+ if (isCodexFirstEventTimeout(error)) return false;
1482
1493
  if (await tryRetryWithoutForcedToolChoice(context, runtime, error)) {
1483
1494
  return true;
1484
1495
  }
@@ -2196,7 +2207,6 @@ function headersToRecord(headers: Headers): Record<string, string> {
2196
2207
 
2197
2208
  interface CodexWebSocketConnectionOptions {
2198
2209
  idleTimeoutMs: number;
2199
- firstEventTimeoutMs: number;
2200
2210
  onHandshakeHeaders?: (headers: Headers) => void;
2201
2211
  }
2202
2212
 
@@ -2204,7 +2214,6 @@ class CodexWebSocketConnection {
2204
2214
  #url: string;
2205
2215
  #headers: Record<string, string>;
2206
2216
  #idleTimeoutMs: number;
2207
- #firstEventTimeoutMs: number;
2208
2217
  #onHandshakeHeaders?: (headers: Headers) => void;
2209
2218
  #socket: Bun.WebSocket | null = null;
2210
2219
  #queue: Array<Record<string, unknown> | Error | null> = [];
@@ -2216,7 +2225,6 @@ class CodexWebSocketConnection {
2216
2225
  this.#url = url;
2217
2226
  this.#headers = headers;
2218
2227
  this.#idleTimeoutMs = options.idleTimeoutMs;
2219
- this.#firstEventTimeoutMs = options.firstEventTimeoutMs;
2220
2228
  this.#onHandshakeHeaders = options.onHandshakeHeaders;
2221
2229
  }
2222
2230
 
@@ -2346,6 +2354,7 @@ class CodexWebSocketConnection {
2346
2354
  async *streamRequest(
2347
2355
  request: Record<string, unknown>,
2348
2356
  signal?: AbortSignal,
2357
+ firstEventTimeoutMs?: number,
2349
2358
  ): AsyncGenerator<Record<string, unknown>> {
2350
2359
  if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
2351
2360
  throw createCodexWebSocketTransportError("websocket connection is unavailable");
@@ -2368,11 +2377,11 @@ class CodexWebSocketConnection {
2368
2377
 
2369
2378
  try {
2370
2379
  this.#socket.send(JSON.stringify(request));
2371
- let sawFirstEvent = false;
2380
+ let sawFirstProgress = false;
2372
2381
  let lastProgressAt = Date.now();
2373
2382
  while (true) {
2374
- let timeoutMs = this.#firstEventTimeoutMs;
2375
- if (sawFirstEvent) {
2383
+ let timeoutMs = firstEventTimeoutMs;
2384
+ if (sawFirstProgress) {
2376
2385
  timeoutMs = this.#idleTimeoutMs - (Date.now() - lastProgressAt);
2377
2386
  if (timeoutMs <= 0) {
2378
2387
  throw createCodexWebSocketTransportError("idle timeout waiting for websocket");
@@ -2380,7 +2389,8 @@ class CodexWebSocketConnection {
2380
2389
  }
2381
2390
  const next = await this.#nextMessage(
2382
2391
  timeoutMs,
2383
- sawFirstEvent ? "idle timeout waiting for websocket" : "timeout waiting for first websocket event",
2392
+ sawFirstProgress ? "idle timeout waiting for websocket" : "timeout waiting for first websocket event",
2393
+ sawFirstProgress ? undefined : STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
2384
2394
  );
2385
2395
  if (next instanceof Error) {
2386
2396
  throw next;
@@ -2388,8 +2398,8 @@ class CodexWebSocketConnection {
2388
2398
  if (next === null) {
2389
2399
  throw createCodexWebSocketTransportError("websocket closed before response completion");
2390
2400
  }
2391
- sawFirstEvent = true;
2392
2401
  if (isCodexStreamProgressEvent(next)) {
2402
+ sawFirstProgress = true;
2393
2403
  lastProgressAt = Date.now();
2394
2404
  }
2395
2405
  yield next;
@@ -2425,13 +2435,17 @@ class CodexWebSocketConnection {
2425
2435
  if (waiter) waiter();
2426
2436
  }
2427
2437
 
2428
- async #nextMessage(timeoutMs: number, timeoutReason: string): Promise<Record<string, unknown> | Error | null> {
2438
+ async #nextMessage(
2439
+ timeoutMs: number | undefined,
2440
+ timeoutReason: string,
2441
+ providerCode?: string,
2442
+ ): Promise<Record<string, unknown> | Error | null> {
2429
2443
  while (this.#queue.length === 0) {
2430
2444
  const { promise, resolve } = Promise.withResolvers<void>();
2431
2445
  this.#waiters.push(resolve);
2432
2446
  let timedOut = false;
2433
2447
  let timeout: NodeJS.Timeout | undefined;
2434
- if (timeoutMs > 0) {
2448
+ if (timeoutMs !== undefined && timeoutMs > 0) {
2435
2449
  timeout = setTimeout(() => {
2436
2450
  timedOut = true;
2437
2451
  const waiterIndex = this.#waiters.indexOf(resolve);
@@ -2444,7 +2458,10 @@ class CodexWebSocketConnection {
2444
2458
  await promise;
2445
2459
  if (timeout) clearTimeout(timeout);
2446
2460
  if (timedOut && this.#queue.length === 0) {
2447
- return createCodexWebSocketTransportError(timeoutReason);
2461
+ if (providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE) {
2462
+ this.close("first-event-timeout");
2463
+ }
2464
+ return createCodexWebSocketTransportError(timeoutReason, providerCode);
2448
2465
  }
2449
2466
  }
2450
2467
  return this.#queue.shift() ?? null;
@@ -2473,7 +2490,6 @@ async function getOrCreateCodexWebSocketConnection(
2473
2490
  const idleTimeoutMs = getCodexWebSocketIdleTimeoutMs(options?.streamIdleTimeoutMs);
2474
2491
  state.connection = new CodexWebSocketConnection(url, headerRecord, {
2475
2492
  idleTimeoutMs,
2476
- firstEventTimeoutMs: getCodexWebSocketFirstEventTimeoutMs(idleTimeoutMs, options?.streamFirstEventTimeoutMs),
2477
2493
  onHandshakeHeaders: handshakeHeaders => {
2478
2494
  updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
2479
2495
  },
@@ -2544,9 +2560,10 @@ async function openCodexWebSocketEventStream(
2544
2560
  state: CodexWebSocketSessionState,
2545
2561
  signal?: AbortSignal,
2546
2562
  options?: Pick<OpenAICodexResponsesOptions, "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs">,
2563
+ firstEventTimeoutMs?: number,
2547
2564
  ): Promise<AsyncGenerator<Record<string, unknown>>> {
2548
2565
  const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal, options);
2549
- return connection.streamRequest(request, signal);
2566
+ return connection.streamRequest(request, signal, firstEventTimeoutMs);
2550
2567
  }
2551
2568
 
2552
2569
  function createCodexHeaders(
@@ -47,7 +47,6 @@ import {
47
47
  rewriteCopilotError,
48
48
  } from "../utils/http-inspector";
49
49
  import {
50
- createWatchdog,
51
50
  getOpenAIStreamIdleTimeoutMs,
52
51
  getProviderFirstEventTimeoutFallbackMs,
53
52
  getStreamFirstEventTimeoutMs,
@@ -457,7 +456,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
457
456
  const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
458
457
  let rawRequestDump: RawHttpRequestDump | undefined;
459
458
  const abortTracker = createAbortSourceTracker(options?.signal);
460
- const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
461
459
  const { requestAbortController, requestSignal } = abortTracker;
462
460
 
463
461
  try {
@@ -579,10 +577,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
579
577
  model.provider === "alibaba-token-plan"
580
578
  ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS
581
579
  : getProviderFirstEventTimeoutFallbackMs(model.provider);
582
- const firstEventWatchdog = createWatchdog(
583
- options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
584
- () => abortTracker.abortLocally(firstEventTimeoutAbortError),
585
- );
580
+ const firstEventTimeoutMs =
581
+ options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
586
582
  if (premiumRequestsTotal !== undefined) {
587
583
  output.usage.premiumRequests = premiumRequestsTotal;
588
584
  }
@@ -769,10 +765,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
769
765
  };
770
766
 
771
767
  for await (const chunk of iterateWithIdleTimeout(openaiStream, {
772
- watchdog: firstEventWatchdog,
768
+ firstItemTimeoutMs: firstEventTimeoutMs,
769
+ firstItemErrorMessage: OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE,
773
770
  idleTimeoutMs,
774
771
  errorMessage: "OpenAI completions stream stalled while waiting for the next event",
775
772
  onIdle: () => requestAbortController.abort(),
773
+ onFirstItemTimeout: () => requestAbortController.abort(),
776
774
  abortSignal: options?.signal,
777
775
  isProgressItem: isOpenAICompletionsProgressChunk,
778
776
  })) {
@@ -983,14 +981,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
983
981
  stream.end();
984
982
  } catch (error) {
985
983
  for (const block of output.content) delete (block as any).index;
986
- const firstEventTimeoutError = abortTracker.getLocalAbortReason();
984
+ const localAbortReason = abortTracker.getLocalAbortReason();
987
985
  const capturedErrorResponse = getCapturedErrorResponse?.();
988
986
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
989
- output.errorStatus = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
990
- output.transportFailure = transportFailureFacts(error, capturedErrorResponse);
987
+ output.errorStatus =
988
+ extractHttpStatusFromError(localAbortReason ?? error) ??
989
+ (localAbortReason ? undefined : capturedErrorResponse?.status);
990
+ output.transportFailure = localAbortReason
991
+ ? transportFailureFacts(localAbortReason)
992
+ : transportFailureFacts(error, capturedErrorResponse);
991
993
  output.errorMessage =
992
- firstEventTimeoutError?.message ??
993
- (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
994
+ localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
994
995
  // Some providers via OpenRouter include extra details here.
995
996
  const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
996
997
  if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
@@ -37,7 +37,6 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
37
37
  import { transportFailureFacts } from "../utils/fallback-transport";
38
38
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
39
39
  import {
40
- createWatchdog,
41
40
  getOpenAIStreamIdleTimeoutMs,
42
41
  getStreamFirstEventTimeoutMs,
43
42
  iterateWithIdleTimeout,
@@ -265,7 +264,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
265
264
  );
266
265
  let rawRequestDump: RawHttpRequestDump | undefined;
267
266
  const abortTracker = createAbortSourceTracker(options?.signal);
268
- const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
269
267
  const { requestAbortController, requestSignal } = abortTracker;
270
268
 
271
269
  try {
@@ -337,10 +335,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
337
335
  });
338
336
  const firstEventFallbackMs =
339
337
  model.provider === "alibaba-token-plan" ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS : undefined;
340
- const firstEventWatchdog = createWatchdog(
341
- options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
342
- () => abortTracker.abortLocally(firstEventTimeoutAbortError),
343
- );
338
+ const firstEventTimeoutMs =
339
+ options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
344
340
  if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
345
341
  stream.push({ type: "start", partial: output });
346
342
 
@@ -348,9 +344,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
348
344
  await processResponsesStream(
349
345
  iterateWithIdleTimeout(openaiStream, {
350
346
  idleTimeoutMs,
351
- watchdog: firstEventWatchdog,
347
+ firstItemTimeoutMs: firstEventTimeoutMs,
348
+ firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
352
349
  errorMessage: "OpenAI responses stream stalled while waiting for the next event",
353
350
  onIdle: () => requestAbortController.abort(),
351
+ onFirstItemTimeout: () => requestAbortController.abort(),
354
352
  abortSignal: options?.signal,
355
353
  isProgressItem: isOpenAIResponsesProgressEvent,
356
354
  }),
@@ -389,11 +387,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
389
387
  stream.end();
390
388
  } catch (error) {
391
389
  for (const block of output.content) delete (block as { index?: number }).index;
392
- const firstEventTimeoutError = abortTracker.getLocalAbortReason();
390
+ const localAbortReason = abortTracker.getLocalAbortReason();
393
391
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
394
- output.errorStatus = extractHttpStatusFromError(error);
395
- output.transportFailure = transportFailureFacts(error);
396
- output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
392
+ output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error);
393
+ output.transportFailure = transportFailureFacts(localAbortReason ?? error);
394
+ output.errorMessage = localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
397
395
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
398
396
  // Explicitly mark the poisoned-history rejection so the shared
399
397
  // `invalid_prompt` contract is present even when the SDK error surfaces
@@ -10,6 +10,7 @@
10
10
  * lazy wrappers below), so this file IS the main streaming path's provider
11
11
  * loader: heavy SDKs stay out of the CLI startup parse graph.
12
12
  */
13
+
13
14
  import type {
14
15
  Api,
15
16
  AssistantMessage,
@@ -21,7 +22,13 @@ import type {
21
22
  } from "../types";
22
23
  import { type AbortSourceTracker, createAbortSourceTracker } from "../utils/abort";
23
24
  import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
24
- import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
25
+ import { transportFailureFacts } from "../utils/fallback-transport";
26
+ import {
27
+ FirstEventTimeoutError,
28
+ getStreamFirstEventTimeoutMs,
29
+ getStreamIdleTimeoutMs,
30
+ iterateWithIdleTimeout,
31
+ } from "../utils/idle-iterator";
25
32
  import type { BedrockOptions } from "./amazon-bedrock";
26
33
  import type { AnthropicOptions } from "./anthropic";
27
34
  import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
@@ -229,7 +236,8 @@ function forwardStream<TApi extends Api>(
229
236
  errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
230
237
  firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
231
238
  onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
232
- onFirstItemTimeout: () => abortTracker.abortLocally(new Error(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
239
+ onFirstItemTimeout: () =>
240
+ abortTracker.abortLocally(new FirstEventTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
233
241
  abortSignal: options.signal,
234
242
  // The synthetic `start` event is yielded immediately by every provider before
235
243
  // the upstream model has emitted any tokens. Treating it as the first "real"
@@ -261,6 +269,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
261
269
  error: unknown,
262
270
  stopReason: Extract<AssistantMessage["stopReason"], "aborted" | "error"> = "error",
263
271
  ): AssistantMessage {
272
+ const transportFailure = transportFailureFacts(error);
264
273
  return {
265
274
  role: "assistant",
266
275
  content: [],
@@ -278,6 +287,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
278
287
  stopReason,
279
288
  errorMessage:
280
289
  stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
290
+ ...(transportFailure ? { transportFailure } : {}),
281
291
  timestamp: Date.now(),
282
292
  };
283
293
  }
@@ -5,6 +5,9 @@ export interface FallbackTrigger {
5
5
  retryAfterMs?: number;
6
6
  }
7
7
 
8
+ /** Stable code for streams that time out before producing semantic progress. */
9
+ export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
10
+
8
11
  export type TransportHeaders = Headers | Record<string, string | undefined>;
9
12
 
10
13
  /**
@@ -185,7 +188,8 @@ export function transportFailureFacts(
185
188
  !isQuotaCode(normalizedCode) &&
186
189
  !isAuthCode(normalizedCode) &&
187
190
  !isRateLimitCode(normalizedCode) &&
188
- !isContextOverflowCode(normalizedCode)
191
+ !isContextOverflowCode(normalizedCode) &&
192
+ normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
189
193
  ) {
190
194
  return undefined;
191
195
  }
@@ -256,14 +260,17 @@ export function classifyFallbackTrigger(
256
260
  parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
257
261
  parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
258
262
  const code = (facts.openaiErrorCode ?? facts.anthropicErrorType ?? facts.providerCode)?.toLowerCase();
259
- const triggerClass: FallbackTriggerClass = isQuotaCode(code)
260
- ? "quota"
261
- : facts.status === 401 || facts.status === 403 || isAuthCode(code)
262
- ? "auth"
263
- : facts.status === 429 || isRateLimitCode(code)
264
- ? "rate_limit"
265
- : facts.status !== undefined && facts.status >= 500 && facts.status <= 599
266
- ? "server"
267
- : "other";
263
+ const triggerClass: FallbackTriggerClass =
264
+ code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
265
+ ? "server"
266
+ : isQuotaCode(code)
267
+ ? "quota"
268
+ : facts.status === 401 || facts.status === 403 || isAuthCode(code)
269
+ ? "auth"
270
+ : facts.status === 429 || isRateLimitCode(code)
271
+ ? "rate_limit"
272
+ : facts.status !== undefined && facts.status >= 500 && facts.status <= 599
273
+ ? "server"
274
+ : "other";
268
275
  return retryAfterMs === undefined ? { class: triggerClass } : { class: triggerClass, retryAfterMs };
269
276
  }