@gajae-code/ai 0.12.0 → 0.12.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.
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import { APP_NAME, extractHttpStatusFromError, getLogsDir } from "@gajae-code/utils";
3
4
  import { isCopilotTransientModelError } from "./retry.js";
@@ -94,6 +95,49 @@ export function formatModelUnavailableGuidance(dump: RawHttpRequestDump | undefi
94
95
  ].join("\n");
95
96
  }
96
97
 
98
+ /**
99
+ * Cap on retained HTTP 400 request dumps.
100
+ *
101
+ * Each dump carries the full sanitized request body, so they are large: a
102
+ * developer machine accumulated 27,249 files totalling 7.0 GB, averaging 264 KB
103
+ * each, because nothing ever removed them. The rotating application log already
104
+ * bounds itself (`maxSize: 10m`, `maxFiles: 5`); these diagnostics get the same
105
+ * treatment so the newest failures stay available without unbounded growth.
106
+ */
107
+ const MAX_RETAINED_DUMPS = 50;
108
+
109
+ /** Directory holding the retained HTTP 400 dumps. */
110
+ export function httpRequestDumpDir(): string {
111
+ return path.join(getLogsDir(), "http-400-requests");
112
+ }
113
+
114
+ /**
115
+ * Drop the oldest dumps beyond the cap. Best-effort: diagnostics must never turn
116
+ * a request failure into a second failure, so every step swallows its error.
117
+ *
118
+ * File names are `${Date.now()}-${hash}.json`, so a lexical sort is chronological
119
+ * for the millisecond timestamps this writer produces.
120
+ */
121
+ export async function pruneHttpRequestDumps(dir: string = httpRequestDumpDir()): Promise<number> {
122
+ const entries = await fs.readdir(dir).catch(() => undefined);
123
+ if (!entries) return 0;
124
+
125
+ const dumps = entries.filter(name => name.endsWith(".json")).sort();
126
+ if (dumps.length <= MAX_RETAINED_DUMPS) return 0;
127
+
128
+ let removed = 0;
129
+ for (const name of dumps.slice(0, dumps.length - MAX_RETAINED_DUMPS)) {
130
+ if (
131
+ await fs.rm(path.join(dir, name), { force: true }).then(
132
+ () => true,
133
+ () => false,
134
+ )
135
+ )
136
+ removed++;
137
+ }
138
+ return removed;
139
+ }
140
+
97
141
  export async function appendRawHttpRequestDumpFor400(
98
142
  message: string,
99
143
  error: unknown,
@@ -105,10 +149,12 @@ export async function appendRawHttpRequestDumpFor400(
105
149
 
106
150
  const sanitizedDump = sanitizeDump(dump);
107
151
  const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(sanitizedDump)).toString(36)}.json`;
108
- const filePath = path.join(getLogsDir(), "http-400-requests", fileName);
152
+ const dumpDir = httpRequestDumpDir();
153
+ const filePath = path.join(dumpDir, fileName);
109
154
 
110
155
  try {
111
156
  await Bun.write(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`);
157
+ await pruneHttpRequestDumps(dumpDir);
112
158
  return `${message}\nraw-http-request=${filePath}\n${RAW_HTTP_REQUEST_PRIVACY_NOTE}`;
113
159
  } catch (writeError) {
114
160
  const writeMessage = writeError instanceof Error ? writeError.message : String(writeError);
@@ -1,4 +1,5 @@
1
1
  import { $env } from "@gajae-code/utils";
2
+ import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE } from "./fallback-transport";
2
3
 
3
4
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
4
5
  const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
@@ -66,6 +67,14 @@ export function getStreamFirstEventTimeoutMs(
66
67
  }
67
68
 
68
69
  export type Watchdog = NodeJS.Timeout | undefined;
70
+ export class FirstEventTimeoutError extends Error {
71
+ readonly providerCode = STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
72
+
73
+ constructor(message: string) {
74
+ super(message);
75
+ this.name = "FirstEventTimeoutError";
76
+ }
77
+ }
69
78
 
70
79
  const dummyWatchdog = setTimeout(() => {}, 1);
71
80
  clearTimeout(dummyWatchdog);
@@ -228,9 +237,9 @@ export async function* iterateWithIdleTimeout<T>(
228
237
  options.onFirstItemTimeout?.();
229
238
  }
230
239
  closeIterator();
231
- throw new Error(
232
- !awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
233
- );
240
+ throw awaitingFirstItem
241
+ ? new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage)
242
+ : new Error(options.errorMessage);
234
243
  }
235
244
  if (outcome.kind === "error") {
236
245
  throw outcome.error;
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { scheduler } from "node:timers/promises";
10
- import { $env, getAgentDir, isEnoent } from "@gajae-code/utils";
10
+ import { $pickCredentialEnv, getAgentDir, isEnoent } from "@gajae-code/utils";
11
11
  import packageJson from "../../../package.json" with { type: "json" };
12
12
  import type { OAuthController, OAuthCredentials } from "./types";
13
13
 
@@ -38,8 +38,23 @@ interface TokenResponse {
38
38
  interval?: number;
39
39
  }
40
40
 
41
+ /**
42
+ * OAuth host for the device flow, from trusted environment sources only.
43
+ *
44
+ * This host receives the device-authorization request, the authorization-code
45
+ * exchange, and the refresh call that carries the existing refresh token, so
46
+ * whatever can set it can collect the user's Kimi credentials. `$env` merges the
47
+ * caller's `cwd/.env`, so reading it there would let repository content redirect
48
+ * the login flow. Resolve it the same way the credentials themselves are:
49
+ * launching shell plus GJC/user-owned `.env` files, never the project `.env`.
50
+ */
41
51
  function resolveOAuthHost(): string {
42
- return $env.KIMI_CODE_OAUTH_HOST || $env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
52
+ return $pickCredentialEnv("KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST") || DEFAULT_OAUTH_HOST;
53
+ }
54
+
55
+ /** Test seam: the OAuth host as resolved from trusted env. */
56
+ export function resolveKimiOAuthHostForTest(): string {
57
+ return resolveOAuthHost();
43
58
  }
44
59
 
45
60
  function formatDeviceModel(system: string, release: string, arch: string): string {
@@ -186,13 +186,32 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
186
186
  *
187
187
  * No browser/manual token paste fallback is used.
188
188
  */
189
+ /**
190
+ * Whether the operator disabled borrowing a token from the native macOS app.
191
+ *
192
+ * `GJC_AUTH_NO_BORROW` is the documented name; `PI_AUTH_NO_BORROW` is the legacy
193
+ * one that was the only name actually read.
194
+ */
195
+ function authBorrowDisabled(): boolean {
196
+ return Boolean($env.GJC_AUTH_NO_BORROW || $env.PI_AUTH_NO_BORROW);
197
+ }
198
+
199
+ /** Test seam: the resolved native-app borrowing opt-out. */
200
+ export function authBorrowDisabledForTest(): boolean {
201
+ return authBorrowDisabled();
202
+ }
203
+
189
204
  export async function loginPerplexity(ctrl: OAuthController): Promise<OAuthCredentials> {
190
205
  if (!ctrl.onPrompt) {
191
206
  throw new Error("Perplexity login requires onPrompt callback");
192
207
  }
193
208
 
194
- // Path 1: Native macOS app JWT (skip if PI_AUTH_NO_BORROW=1)
195
- if (!$env.PI_AUTH_NO_BORROW) {
209
+ // Path 1: Native macOS app JWT, skipped when the operator opts out.
210
+ //
211
+ // Presence-based on purpose: this is a privacy opt-out, so any set value must
212
+ // disable borrowing. A boolean contract would let `GJC_AUTH_NO_BORROW=0`
213
+ // silently re-enable reading a token out of another application.
214
+ if (!authBorrowDisabled()) {
196
215
  ctrl.onProgress?.("Checking for Perplexity desktop app...");
197
216
  const nativeJwt = await extractFromNativeApp();
198
217
  if (nativeJwt) {
@@ -1,4 +1,4 @@
1
- import { $flag } from "@gajae-code/utils";
1
+ import { $pickflag } from "@gajae-code/utils";
2
2
  import { upgradeJsonSchemaTo202012 } from "./draft";
3
3
  import { tryEnforceStrictSchema } from "./normalize";
4
4
 
@@ -11,7 +11,7 @@ import { tryEnforceStrictSchema } from "./normalize";
11
11
  * see `openai-completions`, `openai-responses`, `OpenAI code provider-responses`, and
12
12
  * the strict candidate selection in `anthropic`.
13
13
  */
14
- export const NO_STRICT = $flag("PI_NO_STRICT");
14
+ export const NO_STRICT = $pickflag("GJC_NO_STRICT", "PI_NO_STRICT");
15
15
 
16
16
  /**
17
17
  * Consolidated helper for OpenAI-style strict schema enforcement.
@@ -175,7 +175,8 @@ export function isForcedToolChoiceUnsupportedError(error: unknown, sentForcedToo
175
175
  message,
176
176
  ) ||
177
177
  /forces?\s+tool\s+use.*?(not\s+compatible|incompatible|not\s+supported)/is.test(message) ||
178
- /does\s+not\s+support\s+forced\s+tool[_\s-]?choices?/is.test(message)
178
+ /does\s+not\s+support\s+forced\s+tool[_\s-]?choices?/is.test(message) ||
179
+ /tool[_\s-]?choices?\s+['"`][^'"`\r\n]+['"`]\s+not\s+found\s+in\s+['"`]tools['"`]\s+parameter\b/is.test(message)
179
180
  );
180
181
  }
181
182