@gajae-code/ai 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.4] - 2026-06-02
6
+
7
+ ### Added
8
+
9
+ - Added configurable provider request and stream retry budgets so hosts can bound transient upstream/server retry behavior separately from session-level retries.
10
+
5
11
  ## [0.2.2] - 2026-05-31
6
12
 
7
13
  ### Fixed
@@ -118,6 +118,7 @@ export type AnthropicClientOptionsArgs = {
118
118
  hasTools?: boolean;
119
119
  onSseEvent?: AnthropicOptions["onSseEvent"];
120
120
  fetch?: FetchImpl;
121
+ requestMaxRetries?: number;
121
122
  };
122
123
  export type AnthropicClientOptionsResult = {
123
124
  isOAuthToken: boolean;
@@ -178,6 +178,16 @@ export interface StreamOptions {
178
178
  * Default: 60000 (60 seconds). Set to 0 to disable the cap.
179
179
  */
180
180
  maxRetryDelayMs?: number;
181
+ /**
182
+ * Maximum provider request retries for transports/SDKs that retry before a stream is established.
183
+ * Counts retries only, not the initial attempt. Providers keep their built-in default when unset.
184
+ */
185
+ requestMaxRetries?: number;
186
+ /**
187
+ * Maximum provider stream replay retries after a replay-safe transient stream failure.
188
+ * Counts retries only, not the initial stream attempt. Providers keep their built-in default when unset.
189
+ */
190
+ streamMaxRetries?: number;
181
191
  /**
182
192
  * Optional metadata to include in API requests.
183
193
  * Providers extract the fields they understand and ignore the rest.
@@ -7,6 +7,8 @@ export interface OAuthCallbackFlowOptions {
7
7
  preferredPort: number;
8
8
  callbackPath?: string;
9
9
  callbackHostname?: string;
10
+ /** Local listener hostname; defaults to callbackHostname when omitted. */
11
+ callbackBindHostname?: string;
10
12
  /** Exact redirect URI advertised to the provider; disables port fallback. */
11
13
  redirectUri?: string;
12
14
  }
@@ -19,6 +21,7 @@ export declare abstract class OAuthCallbackFlow {
19
21
  preferredPort: number;
20
22
  callbackPath: string;
21
23
  callbackHostname: string;
24
+ callbackBindHostname: string;
22
25
  redirectUri?: string;
23
26
  constructor(ctrl: OAuthController, preferredPortOrOptions: number | OAuthCallbackFlowOptions, callbackPath?: string);
24
27
  /**
@@ -0,0 +1 @@
1
+ export declare function resolveRetryBudget(value: number | undefined, fallback: number): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.2.3",
4
+ "version": "0.2.5",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@gajae-code/utils": "0.2.3",
46
+ "@gajae-code/utils": "0.2.5",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -281,7 +281,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
281
281
 
282
282
  async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
283
283
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
284
- if (!entry || entry.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
284
+ if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
285
285
  const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
286
286
  if (remainingMs > WAIT_THRESHOLD_MS) return false;
287
287
  return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
@@ -2733,7 +2733,7 @@ export class AuthStorage {
2733
2733
  if (!prepare) return true;
2734
2734
  const stored = this.#getStoredCredentials(provider);
2735
2735
  const selected = stored[selection.index];
2736
- if (!selected || selected.credential.type !== "oauth") return false;
2736
+ if (selected?.credential.type !== "oauth") return false;
2737
2737
 
2738
2738
  const prepared = await prepare(selected.id, { signal: options?.signal });
2739
2739
  if (!prepared) return true;
@@ -2745,7 +2745,7 @@ export class AuthStorage {
2745
2745
  const latestIndex = latestRows.findIndex(row => row.id === selected.id);
2746
2746
  if (latestIndex === -1) return false;
2747
2747
  const latest = latestRows[latestIndex];
2748
- if (!latest || latest.credential.type !== "oauth") return false;
2748
+ if (latest?.credential.type !== "oauth") return false;
2749
2749
  selection.index = latestIndex;
2750
2750
  selection.credential = latest.credential;
2751
2751
  return true;
@@ -31,6 +31,7 @@ import { normalizeToolCallId, resolveCacheRetention } from "../utils";
31
31
  import { AssistantMessageEventStream } from "../utils/event-stream";
32
32
  import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
33
33
  import { parseStreamingJson } from "../utils/json-parse";
34
+ import { resolveRetryBudget } from "../utils/retry-budget";
34
35
  import { toolWireSchema } from "../utils/schema/wire";
35
36
  import { resolveAwsCredentials } from "./aws-credentials";
36
37
  import { decodeEventStream } from "./aws-eventstream";
@@ -259,6 +260,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
259
260
  headers: requestHeaders,
260
261
  body,
261
262
  signal: options.signal,
263
+ maxAttempts: resolveRetryBudget(options.requestMaxRetries, 4) + 1,
262
264
  });
263
265
 
264
266
  if (!response.ok) {
@@ -61,6 +61,7 @@ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
61
61
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
62
62
  import { notifyProviderResponse } from "../utils/provider-response";
63
63
  import { isCopilotTransientModelError } from "../utils/retry";
64
+ import { resolveRetryBudget } from "../utils/retry-budget";
64
65
  import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
65
66
  import { spillToDescription } from "../utils/schema/spill";
66
67
  import { notifyRawSseEvent, wrapFetchForSseDebug } from "../utils/sse-debug";
@@ -183,12 +184,14 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
183
184
  "User-Agent": userAgent,
184
185
  };
185
186
  } else if (!isAnthropicApiBaseUrl(options.baseUrl)) {
187
+ const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent");
186
188
  return {
187
189
  ...modelHeaders,
188
190
  Accept: acceptHeader,
189
191
  Authorization: `Bearer ${options.apiKey}`,
190
192
  ...sharedHeaders,
191
193
  "Anthropic-Beta": betaHeader,
194
+ ...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
192
195
  };
193
196
  } else {
194
197
  return {
@@ -626,6 +629,7 @@ export type AnthropicClientOptionsArgs = {
626
629
  hasTools?: boolean;
627
630
  onSseEvent?: AnthropicOptions["onSseEvent"];
628
631
  fetch?: FetchImpl;
632
+ requestMaxRetries?: number;
629
633
  };
630
634
 
631
635
  export type AnthropicClientOptionsResult = {
@@ -1057,6 +1061,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1057
1061
  hasTools: !!context.tools?.length,
1058
1062
  onSseEvent: options?.onSseEvent,
1059
1063
  fetch: options?.fetch,
1064
+ requestMaxRetries: options?.requestMaxRetries,
1060
1065
  });
1061
1066
  client = created.client;
1062
1067
  isOAuthToken = created.isOAuthToken;
@@ -1445,7 +1450,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1445
1450
  firstTokenTime === undefined && isProviderRetryableError(streamFailure, model.provider);
1446
1451
  if (
1447
1452
  activeAbortTracker.wasCallerAbort() ||
1448
- providerRetryAttempt >= PROVIDER_MAX_RETRIES ||
1453
+ providerRetryAttempt >= resolveRetryBudget(options?.streamMaxRetries, PROVIDER_MAX_RETRIES) ||
1449
1454
  (!canRetryTransientEnvelopeFailure && !canRetryProviderFailure)
1450
1455
  ) {
1451
1456
  throw streamFailure;
@@ -1604,7 +1609,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
1604
1609
  apiKey: null,
1605
1610
  authToken: copilotApiKey,
1606
1611
  baseURL: baseUrl,
1607
- maxRetries: 5,
1612
+ maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
1608
1613
  dangerouslyAllowBrowser: true,
1609
1614
  defaultHeaders,
1610
1615
  logLevel: ANTHROPIC_SDK_LOG_LEVEL,
@@ -1637,7 +1642,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
1637
1642
  apiKey: null,
1638
1643
  authToken: null,
1639
1644
  baseURL: baseUrl,
1640
- maxRetries: 5,
1645
+ maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
1641
1646
  dangerouslyAllowBrowser: true,
1642
1647
  defaultHeaders,
1643
1648
  logLevel: ANTHROPIC_SDK_LOG_LEVEL,
@@ -1650,7 +1655,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
1650
1655
  apiKey: oauthToken ? null : apiKey,
1651
1656
  authToken: oauthToken ? apiKey : undefined,
1652
1657
  baseURL: baseUrl,
1653
- maxRetries: 5,
1658
+ maxRetries: resolveRetryBudget(args.requestMaxRetries, 5),
1654
1659
  dangerouslyAllowBrowser: true,
1655
1660
  defaultHeaders,
1656
1661
  logLevel: ANTHROPIC_SDK_LOG_LEVEL,
@@ -1679,7 +1684,7 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming)
1679
1684
 
1680
1685
  function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: Model<"anthropic-messages">): void {
1681
1686
  const thinking = params.thinking;
1682
- if (!thinking || thinking.type !== "enabled") return;
1687
+ if (thinking?.type !== "enabled") return;
1683
1688
 
1684
1689
  const budgetTokens = thinking.budget_tokens ?? 0;
1685
1690
  if (budgetTokens <= 0) return;
@@ -26,6 +26,7 @@ import {
26
26
  getStreamFirstEventTimeoutMs,
27
27
  iterateWithIdleTimeout,
28
28
  } from "../utils/idle-iterator";
29
+ import { resolveRetryBudget } from "../utils/retry-budget";
29
30
  import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
30
31
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
31
32
  import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
@@ -249,7 +250,7 @@ function createClient(model: Model<"azure-openai-responses">, apiKey: string, op
249
250
  apiKey,
250
251
  apiVersion,
251
252
  dangerouslyAllowBrowser: true,
252
- maxRetries: 5,
253
+ maxRetries: resolveRetryBudget(options?.requestMaxRetries, 5),
253
254
  defaultHeaders: headers,
254
255
  baseURL: baseUrl,
255
256
  fetch: onSseEvent ? wrapFetchForSseDebug(baseFetch, event => onSseEvent(event, model)) : baseFetch,
@@ -21,6 +21,7 @@ import type {
21
21
  import { normalizeSystemPrompts } from "../utils";
22
22
  import { AssistantMessageEventStream } from "../utils/event-stream";
23
23
  import { appendRawHttpRequestDumpFor400, type RawHttpRequestDump, withHttpStatus } from "../utils/http-inspector";
24
+ import { resolveRetryBudget } from "../utils/retry-budget";
24
25
  // Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
25
26
  // the stream provider trusts the access token threaded through `options.apiKey`.
26
27
  import { normalizeSchemaForCCA } from "../utils/schema";
@@ -340,7 +341,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
340
341
  headers: requestHeaders,
341
342
  body: requestBodyJson,
342
343
  signal: options?.signal,
343
- maxAttempts: MAX_RETRIES + 1,
344
+ maxAttempts: resolveRetryBudget(options?.requestMaxRetries, MAX_RETRIES) + 1,
344
345
  defaultDelayMs: attempt => BASE_DELAY_MS * 2 ** attempt,
345
346
  maxDelayMs: options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS,
346
347
  fetch: options?.fetch,
@@ -509,7 +510,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
509
510
  let receivedContent = false;
510
511
  let currentResponse = response;
511
512
 
512
- for (let emptyAttempt = 0; emptyAttempt <= MAX_EMPTY_STREAM_RETRIES; emptyAttempt++) {
513
+ const emptyStreamRetryBudget = resolveRetryBudget(options?.streamMaxRetries, MAX_EMPTY_STREAM_RETRIES);
514
+ for (let emptyAttempt = 0; emptyAttempt <= emptyStreamRetryBudget; emptyAttempt++) {
513
515
  if (options?.signal?.aborted) {
514
516
  throw new Error("Request was aborted");
515
517
  }
@@ -549,7 +551,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
549
551
  break;
550
552
  }
551
553
 
552
- if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) {
554
+ if (emptyAttempt < emptyStreamRetryBudget) {
553
555
  resetOutput();
554
556
  }
555
557
  }
@@ -18,6 +18,7 @@ import { normalizeSystemPrompts } from "../utils";
18
18
  import { AssistantMessageEventStream } from "../utils/event-stream";
19
19
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
20
20
  import { parseStreamingJson } from "../utils/json-parse";
21
+ import { resolveRetryBudget } from "../utils/retry-budget";
21
22
  import { toolWireSchema } from "../utils/schema/wire";
22
23
  import { transformMessages } from "./transform-messages";
23
24
 
@@ -402,6 +403,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
402
403
  },
403
404
  body: JSON.stringify(body),
404
405
  signal: options.signal,
406
+ maxAttempts: resolveRetryBudget(options.requestMaxRetries, 4) + 1,
405
407
  defaultDelayMs: OLLAMA_RETRY_DELAYS_MS,
406
408
  fetch: options.fetch,
407
409
  });
@@ -49,6 +49,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
49
49
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
50
50
  import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
51
51
  import { parseStreamingJson } from "../utils/json-parse";
52
+ import { resolveRetryBudget } from "../utils/retry-budget";
52
53
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
53
54
  import { compactGrammarDefinition } from "./grammar";
54
55
  import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
@@ -227,7 +228,10 @@ function isCodexWebSocketEnvEnabled(): boolean {
227
228
  return $flag("PI_CODEX_WEBSOCKET");
228
229
  }
229
230
 
230
- function getCodexWebSocketRetryBudget(): number {
231
+ function getCodexWebSocketRetryBudget(options?: Pick<OpenAICodexResponsesOptions, "streamMaxRetries">): number {
232
+ if (options?.streamMaxRetries !== undefined) {
233
+ return resolveRetryBudget(options.streamMaxRetries, CODEX_WEBSOCKET_RETRY_BUDGET);
234
+ }
231
235
  return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET);
232
236
  }
233
237
 
@@ -641,7 +645,7 @@ async function openInitialCodexEventStream(
641
645
  }> {
642
646
  const { transformedBody, websocketState } = requestContext;
643
647
  if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) {
644
- const websocketRetryBudget = getCodexWebSocketRetryBudget();
648
+ const websocketRetryBudget = getCodexWebSocketRetryBudget(options);
645
649
  let websocketRetries = 0;
646
650
  while (true) {
647
651
  try {
@@ -707,7 +711,7 @@ async function openCodexWebSocketTransport(
707
711
  sentModelsEtagHeader: websocketHeaders.has(X_MODELS_ETAG_HEADER),
708
712
  requestType: websocketRequest.type,
709
713
  retry,
710
- retryBudget: getCodexWebSocketRetryBudget(),
714
+ retryBudget: getCodexWebSocketRetryBudget(options),
711
715
  });
712
716
  const eventStream = await openCodexWebSocketEventStream(
713
717
  toWebSocketUrl(requestContext.url),
@@ -744,6 +748,7 @@ async function openCodexSseTransport(
744
748
  requestSetup.requestSignal,
745
749
  event => options?.onSseEvent?.(event, model),
746
750
  options?.fetch,
751
+ options,
747
752
  ),
748
753
  );
749
754
  return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
@@ -1348,7 +1353,7 @@ async function tryRecoverCodexPreviousResponseNotFound(
1348
1353
  runtime.transport !== "websocket" ||
1349
1354
  context.output.content.length > 0 ||
1350
1355
  context.options?.signal?.aborted ||
1351
- runtime.providerRetryAttempt >= CODEX_MAX_RETRIES
1356
+ runtime.providerRetryAttempt >= resolveRetryBudget(context.options?.streamMaxRetries, CODEX_MAX_RETRIES)
1352
1357
  ) {
1353
1358
  return false;
1354
1359
  }
@@ -1390,12 +1395,14 @@ async function tryReplayWebsocketFailureOverSse(
1390
1395
  const replayingBufferedOutputOverSse = context.output.content.length > 0;
1391
1396
  const isFatal = isCodexWebSocketFatalError(streamError);
1392
1397
  const activateFallback =
1393
- replayingBufferedOutputOverSse || isFatal || runtime.websocketStreamRetries >= getCodexWebSocketRetryBudget();
1398
+ replayingBufferedOutputOverSse ||
1399
+ isFatal ||
1400
+ runtime.websocketStreamRetries >= getCodexWebSocketRetryBudget(context.options);
1394
1401
  recordCodexWebSocketFailure(state, activateFallback);
1395
1402
  logCodexDebug("codex websocket stream fallback", {
1396
1403
  error: streamError.message,
1397
1404
  retry: runtime.websocketStreamRetries,
1398
- retryBudget: getCodexWebSocketRetryBudget(),
1405
+ retryBudget: getCodexWebSocketRetryBudget(context.options),
1399
1406
  activated: activateFallback,
1400
1407
  fatal: isFatal,
1401
1408
  replayedBufferedOutput: replayingBufferedOutputOverSse,
@@ -1431,7 +1438,7 @@ async function tryRetryCodexProviderError(
1431
1438
  if (
1432
1439
  !isRetryableCodexProviderError(error) ||
1433
1440
  context.output.content.length > 0 ||
1434
- runtime.providerRetryAttempt >= CODEX_MAX_RETRIES ||
1441
+ runtime.providerRetryAttempt >= resolveRetryBudget(context.options?.streamMaxRetries, CODEX_MAX_RETRIES) ||
1435
1442
  context.options?.signal?.aborted
1436
1443
  ) {
1437
1444
  return false;
@@ -1447,7 +1454,7 @@ async function tryRetryCodexProviderError(
1447
1454
  logCodexDebug("retrying codex provider stream error", {
1448
1455
  error: error instanceof Error ? error.message : String(error),
1449
1456
  retry: runtime.providerRetryAttempt,
1450
- retryBudget: CODEX_MAX_RETRIES,
1457
+ retryBudget: resolveRetryBudget(context.options?.streamMaxRetries, CODEX_MAX_RETRIES),
1451
1458
  transport: runtime.transport,
1452
1459
  });
1453
1460
 
@@ -2187,6 +2194,7 @@ async function openCodexSseEventStream(
2187
2194
  signal?: AbortSignal,
2188
2195
  onSseEvent?: OpenAICodexResponsesOptions["onSseEvent"],
2189
2196
  fetchOverride?: FetchImpl,
2197
+ options?: Pick<OpenAICodexResponsesOptions, "requestMaxRetries">,
2190
2198
  ): Promise<AsyncGenerator<Record<string, unknown>>> {
2191
2199
  const headers = createCodexHeaders(requestHeaders, accountId, apiKey, sessionId, "sse", state);
2192
2200
  logCodexDebug("codex request", {
@@ -2201,7 +2209,7 @@ async function openCodexSseEventStream(
2201
2209
  headers,
2202
2210
  body: JSON.stringify(body),
2203
2211
  signal,
2204
- maxAttempts: CODEX_MAX_RETRIES + 1,
2212
+ maxAttempts: resolveRetryBudget(options?.requestMaxRetries, CODEX_MAX_RETRIES) + 1,
2205
2213
  defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1),
2206
2214
  maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS,
2207
2215
  fetch: fetchOverride,
@@ -56,6 +56,7 @@ import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
56
56
  import { getKimiCommonHeaders } from "../utils/oauth/kimi";
57
57
  import { notifyProviderResponse } from "../utils/provider-response";
58
58
  import { callWithCopilotModelRetry } from "../utils/retry";
59
+ import { resolveRetryBudget } from "../utils/retry-budget";
59
60
  import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
60
61
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
61
62
  import { type HealedToolCall, modelMayLeakKimiToolCalls, ToolCallHealer } from "../utils/tool-call-healing";
@@ -444,6 +445,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
444
445
  options?.fetch,
445
446
  options?.streamFirstEventTimeoutMs,
446
447
  options?.authCredentialType,
448
+ options?.requestMaxRetries,
447
449
  );
448
450
  const premiumRequestsTotal = copilotPremiumRequests;
449
451
  getCapturedErrorResponse = captureErrorResponse;
@@ -552,7 +554,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
552
554
  eventStream: AssistantMessageEventStream,
553
555
  text: string,
554
556
  ): void => {
555
- if (!currentBlock || currentBlock.type !== "text") {
557
+ if (currentBlock?.type !== "text") {
556
558
  finishCurrentBlock(currentBlock);
557
559
  currentBlock = { type: "text", text: "" };
558
560
  message.content.push(currentBlock);
@@ -573,8 +575,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
573
575
  signature?: string,
574
576
  ): void => {
575
577
  if (
576
- !currentBlock ||
577
- currentBlock.type !== "thinking" ||
578
+ currentBlock?.type !== "thinking" ||
578
579
  (signature !== undefined && currentBlock.thinkingSignature !== signature)
579
580
  ) {
580
581
  finishCurrentBlock(currentBlock);
@@ -784,11 +785,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
784
785
 
785
786
  if (choice?.delta?.tool_calls && choice.delta.tool_calls.length > 0) {
786
787
  for (const toolCall of choice.delta.tool_calls) {
787
- if (
788
- !currentBlock ||
789
- currentBlock.type !== "toolCall" ||
790
- (toolCall.id && currentBlock.id !== toolCall.id)
791
- ) {
788
+ if (currentBlock?.type !== "toolCall" || (toolCall.id && currentBlock.id !== toolCall.id)) {
792
789
  finishCurrentBlock(currentBlock);
793
790
  currentBlock = {
794
791
  type: "toolCall",
@@ -920,6 +917,7 @@ async function createClient(
920
917
  fetchOverride?: FetchImpl,
921
918
  streamFirstEventTimeoutOverride?: number,
922
919
  authCredentialType?: OpenAICompletionsOptions["authCredentialType"],
920
+ requestMaxRetries?: number,
923
921
  ): Promise<{
924
922
  client: OpenAI;
925
923
  copilotPremiumRequests: number | undefined;
@@ -1051,7 +1049,7 @@ async function createClient(
1051
1049
  apiKey,
1052
1050
  baseURL: baseUrl,
1053
1051
  dangerouslyAllowBrowser: true,
1054
- maxRetries: 5,
1052
+ maxRetries: resolveRetryBudget(requestMaxRetries, 5),
1055
1053
  defaultHeaders: headers,
1056
1054
  defaultQuery: azureDefaultQuery,
1057
1055
  fetch: debugFetch,
@@ -954,7 +954,7 @@ export function encodeStream(
954
954
  break;
955
955
  }
956
956
  case "text_delta": {
957
- if (!state.open || state.open.kind !== "message") break;
957
+ if (state.open?.kind !== "message") break;
958
958
  const cur: OpenMessage = state.open;
959
959
  cur.currentPartText += ev.delta;
960
960
  emit("response.output_text.delta", {
@@ -970,7 +970,7 @@ export function encodeStream(
970
970
  break;
971
971
  }
972
972
  case "text_end": {
973
- if (!state.open || state.open.kind !== "message") break;
973
+ if (state.open?.kind !== "message") break;
974
974
  const cur: OpenMessage = state.open;
975
975
  const text = ev.content ?? cur.currentPartText;
976
976
  emit("response.output_text.done", {
@@ -997,7 +997,7 @@ export function encodeStream(
997
997
  break;
998
998
  }
999
999
  case "thinking_delta": {
1000
- if (!state.open || state.open.kind !== "reasoning") break;
1000
+ if (state.open?.kind !== "reasoning") break;
1001
1001
  const cur: OpenReasoning = state.open;
1002
1002
  cur.reasoningText += ev.delta;
1003
1003
  emit("response.reasoning_summary_text.delta", {
@@ -1009,7 +1009,7 @@ export function encodeStream(
1009
1009
  break;
1010
1010
  }
1011
1011
  case "thinking_end": {
1012
- if (!state.open || state.open.kind !== "reasoning") break;
1012
+ if (state.open?.kind !== "reasoning") break;
1013
1013
  const cur: OpenReasoning = state.open;
1014
1014
  const text = ev.content ?? cur.reasoningText;
1015
1015
  cur.reasoningText = text;
@@ -1034,7 +1034,7 @@ export function encodeStream(
1034
1034
  break;
1035
1035
  }
1036
1036
  case "toolcall_delta": {
1037
- if (!state.open || state.open.kind !== "function_call") break;
1037
+ if (state.open?.kind !== "function_call") break;
1038
1038
  const cur: OpenFunctionCall = state.open;
1039
1039
  cur.argsText += ev.delta;
1040
1040
  if (cur.customWireName) {
@@ -1053,7 +1053,7 @@ export function encodeStream(
1053
1053
  break;
1054
1054
  }
1055
1055
  case "toolcall_end": {
1056
- if (!state.open || state.open.kind !== "function_call") break;
1056
+ if (state.open?.kind !== "function_call") break;
1057
1057
  const cur: OpenFunctionCall = state.open;
1058
1058
  // Promote possibly-late info from the canonical ToolCall.
1059
1059
  const tc = ev.toolCall;
@@ -42,6 +42,7 @@ import {
42
42
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
43
43
  import { notifyProviderResponse } from "../utils/provider-response";
44
44
  import { callWithCopilotModelRetry } from "../utils/retry";
45
+ import { resolveRetryBudget } from "../utils/retry-budget";
45
46
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
46
47
  import { wrapFetchForSseDebug } from "../utils/sse-debug";
47
48
  import { mapToOpenAIResponsesToolChoice, type OpenAIResponsesToolChoice } from "../utils/tool-choice";
@@ -253,6 +254,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
253
254
  options?.onSseEvent,
254
255
  options?.fetch,
255
256
  options?.authCredentialType,
257
+ options?.requestMaxRetries,
256
258
  );
257
259
  const premiumRequestsTotal = copilotPremiumRequests;
258
260
  const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
@@ -354,6 +356,7 @@ function createClient(
354
356
  onSseEvent?: OpenAIResponsesOptions["onSseEvent"],
355
357
  fetchOverride?: FetchImpl,
356
358
  authCredentialType?: OpenAIResponsesOptions["authCredentialType"],
359
+ requestMaxRetries?: number,
357
360
  ): {
358
361
  client: OpenAI;
359
362
  copilotPremiumRequests: number | undefined;
@@ -410,7 +413,7 @@ function createClient(
410
413
  apiKey,
411
414
  baseURL: baseUrl,
412
415
  dangerouslyAllowBrowser: true,
413
- maxRetries: 5,
416
+ maxRetries: resolveRetryBudget(requestMaxRetries, 5),
414
417
  defaultHeaders: headers,
415
418
  fetch: onSseEvent
416
419
  ? wrapFetchForSseDebug(transformedFetch, event => onSseEvent(event, model))
package/src/stream.ts CHANGED
@@ -563,6 +563,8 @@ function mapOptionsForApi<TApi extends Api>(
563
563
  headers: options?.headers,
564
564
  initiatorOverride: options?.initiatorOverride,
565
565
  maxRetryDelayMs: options?.maxRetryDelayMs,
566
+ requestMaxRetries: options?.requestMaxRetries,
567
+ streamMaxRetries: options?.streamMaxRetries,
566
568
  metadata: options?.metadata,
567
569
  sessionId: options?.sessionId,
568
570
  providerSessionState: options?.providerSessionState,
package/src/types.ts CHANGED
@@ -310,6 +310,16 @@ export interface StreamOptions {
310
310
  * Default: 60000 (60 seconds). Set to 0 to disable the cap.
311
311
  */
312
312
  maxRetryDelayMs?: number;
313
+ /**
314
+ * Maximum provider request retries for transports/SDKs that retry before a stream is established.
315
+ * Counts retries only, not the initial attempt. Providers keep their built-in default when unset.
316
+ */
317
+ requestMaxRetries?: number;
318
+ /**
319
+ * Maximum provider stream replay retries after a replay-safe transient stream failure.
320
+ * Counts retries only, not the initial stream attempt. Providers keep their built-in default when unset.
321
+ */
322
+ streamMaxRetries?: number;
313
323
  /**
314
324
  * Optional metadata to include in API requests.
315
325
  * Providers extract the fields they understand and ignore the rest.
@@ -23,6 +23,8 @@ export interface OAuthCallbackFlowOptions {
23
23
  preferredPort: number;
24
24
  callbackPath?: string;
25
25
  callbackHostname?: string;
26
+ /** Local listener hostname; defaults to callbackHostname when omitted. */
27
+ callbackBindHostname?: string;
26
28
  /** Exact redirect URI advertised to the provider; disables port fallback. */
27
29
  redirectUri?: string;
28
30
  }
@@ -35,6 +37,7 @@ export abstract class OAuthCallbackFlow {
35
37
  preferredPort: number;
36
38
  callbackPath: string;
37
39
  callbackHostname: string;
40
+ callbackBindHostname: string;
38
41
  redirectUri?: string;
39
42
  #callbackResolve?: (result: CallbackResult) => void;
40
43
  #callbackReject?: (error: string) => void;
@@ -49,12 +52,14 @@ export abstract class OAuthCallbackFlow {
49
52
  this.preferredPort = preferredPortOrOptions;
50
53
  this.callbackPath = callbackPath;
51
54
  this.callbackHostname = DEFAULT_HOSTNAME;
55
+ this.callbackBindHostname = DEFAULT_HOSTNAME;
52
56
  return;
53
57
  }
54
58
 
55
59
  this.preferredPort = preferredPortOrOptions.preferredPort;
56
60
  this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
57
61
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
62
+ this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
58
63
  this.redirectUri = preferredPortOrOptions.redirectUri;
59
64
  }
60
65
 
@@ -144,7 +149,7 @@ export abstract class OAuthCallbackFlow {
144
149
  */
145
150
  #createServer(port: number, expectedState: string): Bun.Server<unknown> {
146
151
  return Bun.serve({
147
- hostname: this.callbackHostname,
152
+ hostname: this.callbackBindHostname,
148
153
  port,
149
154
  reusePort: false,
150
155
  fetch: req => this.#handleCallback(req, expectedState),
@@ -0,0 +1,4 @@
1
+ export function resolveRetryBudget(value: number | undefined, fallback: number): number {
2
+ if (value === undefined || !Number.isFinite(value)) return fallback;
3
+ return Math.max(0, Math.floor(value));
4
+ }