@linxiraos/pi-ai 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +7 -7
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. package/src/utils.ts +38 -23
@@ -1,4 +1,6 @@
1
1
  import { scheduler } from "node:timers/promises";
2
+ import { type } from "@linxiraos/pi-omptype";
3
+ import { getSupportedEfforts } from "@linxiraos/pi-catalog/model-thinking";
2
4
  import { calculateCost } from "@linxiraos/pi-catalog/models";
3
5
  import {
4
6
  applyCodexResidencyHeader,
@@ -9,7 +11,6 @@ import {
9
11
  OPENAI_HEADER_VALUES,
10
12
  OPENAI_HEADERS,
11
13
  } from "@linxiraos/pi-catalog/wire/codex";
12
- import { type } from "@linxiraos/pi-omptype";
13
14
  import {
14
15
  $env,
15
16
  $flag,
@@ -25,7 +26,6 @@ import {
25
26
  import * as AIError from "../error";
26
27
  import { parseToolCallArguments, replayableToolCallArguments } from "../utils/tool-call-arguments";
27
28
  import { getEnvApiKey } from "../env-api-key";
28
- import { isOfficialCodexApiUrl } from "../stream";
29
29
  import type {
30
30
  Api,
31
31
  AssistantMessage,
@@ -71,6 +71,7 @@ import {
71
71
  iterateWithIdleTimeout,
72
72
  } from "../utils/idle-iterator";
73
73
  import { getProxyForUrl } from "../utils/proxy";
74
+ import { wrapFetchForRequestCompression } from "../utils/request-compression";
74
75
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
75
76
  import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
76
77
  import { notifyRawSseEvent } from "../utils/sse-debug";
@@ -153,10 +154,16 @@ import {
153
154
  settleReasoningSummary,
154
155
  type SequentialCutoffSummaryState,
155
156
  } from "./openai-shared";
156
- import { redactSensitiveInObject, transformMessages } from "./transform-messages";
157
+ import { normalizeToolCallId, redactSensitiveInObject, transformMessages } from "./transform-messages";
157
158
 
158
159
  export interface OpenAICodexResponsesOptions extends StreamOptions {
159
160
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
161
+ /**
162
+ * Run with the least reasoning the model allows; takes precedence over
163
+ * `reasoning`. Sends the `none` tier where the catalog's disable mode is
164
+ * `none-effort`, else the model's lowest supported effort.
165
+ */
166
+ disableReasoning?: boolean;
160
167
  reasoningSummary?: "auto" | "concise" | "detailed" | null;
161
168
  /** Explicit `reasoning.context` replay scope. Omitted by default so Codex applies its native request policy. */
162
169
  reasoningContext?: CodexReasoningContext;
@@ -498,15 +505,12 @@ interface CodexCompatibilityIdentity {
498
505
  affinitySessionId?: string;
499
506
  threadId: string;
500
507
  windowId: string;
501
- /** Header projection: identity fields only, never the Code Mode snapshot. */
502
- turnMetadataHeaderJson?: string;
508
+ turnMetadataJson?: string;
503
509
  }
504
510
 
505
511
  interface CodexRequestMetadata extends CodexCompatibilityIdentity {
506
512
  turnId: string;
507
- /** Canonical body projection, including `tool_namespaces_info` when present. */
508
513
  turnMetadataJson: string;
509
- turnMetadataHeaderJson: string;
510
514
  clientMetadata: Record<string, string>;
511
515
  }
512
516
 
@@ -523,11 +527,6 @@ const CODEX_RESERVED_METADATA_KEYS: Record<string, true> = {
523
527
  [OPENAI_HEADERS.SUBAGENT]: true,
524
528
  request_kind: true,
525
529
  compaction: true,
526
- // codex-rs retired `code_mode_tool_names` in favor of `tool_namespaces_info`
527
- // for its Responses Lite Code Mode mapping; both stay reserved so callers
528
- // cannot smuggle them in as extras.
529
- code_mode_tool_names: true,
530
- tool_namespaces_info: true,
531
530
  turn_started_at_unix_ms: true,
532
531
  forked_from_thread_id: true,
533
532
  parent_thread_id: true,
@@ -633,7 +632,6 @@ function createCodexRequestMetadata(
633
632
  clientMetadata?: Readonly<Record<string, string>>;
634
633
  parentTurnId?: string;
635
634
  compaction?: CodexCompactionRequestContext;
636
- toolNamespacesInfo?: unknown;
637
635
  affinitySessionId?: string;
638
636
  },
639
637
  ): CodexRequestMetadata {
@@ -674,17 +672,7 @@ function createCodexRequestMetadata(
674
672
  turnMetadata.turn_started_at_unix_ms = session.turnStartedAtUnixMs;
675
673
  }
676
674
  for (const key in extra) turnMetadata[key] = extra[key];
677
- // The `x-codex-turn-metadata` header is capped at 100KB by the Codex
678
- // backend, while `tool_namespaces_info` grows with the session's tool count
679
- // (409 tools ~ 100KB on its own). The body's `client_metadata` is the
680
- // canonical envelope, so the snapshot rides there only and the header keeps
681
- // the fixed-size identity projection.
682
- const turnMetadataHeaderJson = toAsciiJsonString(turnMetadata);
683
- let turnMetadataJson = turnMetadataHeaderJson;
684
- if (options.toolNamespacesInfo !== undefined) {
685
- turnMetadata.tool_namespaces_info = options.toolNamespacesInfo;
686
- turnMetadataJson = toAsciiJsonString(turnMetadata);
687
- }
675
+ const turnMetadataJson = toAsciiJsonString(turnMetadata);
688
676
  const clientMetadata: Record<string, string> = {
689
677
  [OPENAI_HEADERS.INSTALLATION_ID]: identity.installationId,
690
678
  session_id: identity.sessionId,
@@ -701,7 +689,6 @@ function createCodexRequestMetadata(
701
689
  affinitySessionId: options.affinitySessionId,
702
690
  turnId: session.turnId,
703
691
  turnMetadataJson,
704
- turnMetadataHeaderJson,
705
692
  clientMetadata,
706
693
  };
707
694
  }
@@ -710,8 +697,8 @@ function applyCodexCompatibilityHeaders(headers: Headers, metadata: CodexCompati
710
697
  headers.set(OPENAI_HEADERS.SCOPED_SESSION_ID, metadata.affinitySessionId ?? metadata.sessionId);
711
698
  headers.set(OPENAI_HEADERS.THREAD_ID, metadata.threadId);
712
699
  headers.set(OPENAI_HEADERS.WINDOW_ID, metadata.windowId);
713
- if (metadata.turnMetadataHeaderJson) {
714
- headers.set(OPENAI_HEADERS.TURN_METADATA, metadata.turnMetadataHeaderJson);
700
+ if (metadata.turnMetadataJson) {
701
+ headers.set(OPENAI_HEADERS.TURN_METADATA, metadata.turnMetadataJson);
715
702
  } else {
716
703
  headers.delete(OPENAI_HEADERS.TURN_METADATA);
717
704
  }
@@ -1531,7 +1518,6 @@ function createCodexRequestContext(
1531
1518
  clientMetadata: transformedBody.client_metadata,
1532
1519
  parentTurnId: options?.parentTurnId,
1533
1520
  compaction,
1534
- toolNamespacesInfo: options?.toolNamespacesInfo,
1535
1521
  affinitySessionId: normalizeOpenAIPromptCacheKey(options?.parentSessionId),
1536
1522
  });
1537
1523
  transformedBody.client_metadata = requestMetadata.clientMetadata;
@@ -1568,6 +1554,22 @@ async function buildCodexRequestContext(
1568
1554
  });
1569
1555
  }
1570
1556
 
1557
+ /**
1558
+ * Codex reasoning controls for the caller's request. `forceReasoningOff` always
1559
+ * sends the `none` tier. `disableReasoning` follows the catalog disable mode:
1560
+ * `none` where the model accepts it, else the lowest supported effort —
1561
+ * omitting effort would let the backend apply its default tier instead.
1562
+ */
1563
+ function codexReasoningControls(
1564
+ model: Model<"openai-codex-responses">,
1565
+ options: OpenAICodexResponsesOptions | undefined,
1566
+ ): Pick<CodexRequestOptions, "reasoningEffort" | "reasoningOff"> {
1567
+ if (options?.forceReasoningOff) return { reasoningOff: true };
1568
+ if (!options?.disableReasoning) return { reasoningEffort: options?.reasoning };
1569
+ if (model.reasoning && model.compat.reasoningDisableMode === "none-effort") return { reasoningOff: true };
1570
+ return { reasoningEffort: getSupportedEfforts(model)[0] };
1571
+ }
1572
+
1571
1573
  /** Serialize normal Codex turns and V2 compaction with the same cacheable prefix. */
1572
1574
  export async function buildTransformedCodexRequestBody(
1573
1575
  model: Model<"openai-codex-responses">,
@@ -1612,8 +1614,7 @@ export async function buildTransformedCodexRequestBody(
1612
1614
  params.client_metadata = { ...options.clientMetadata };
1613
1615
  }
1614
1616
  const codexOptions: CodexRequestOptions = {
1615
- reasoningEffort: options?.reasoning,
1616
- reasoningOff: options?.forceReasoningOff,
1617
+ ...codexReasoningControls(model, options),
1617
1618
  reasoningSummary: options?.reasoningSummary,
1618
1619
  reasoningContext: options?.reasoningContext,
1619
1620
  textVerbosity: options?.textVerbosity,
@@ -2059,7 +2060,7 @@ async function openCodexSseTransport(
2059
2060
  requestSetup.firstEventTimeoutMs,
2060
2061
  options?.codexSseMaxAttempts,
2061
2062
  event => options?.onSseEvent?.(event, model),
2062
- options?.fetch,
2063
+ wrapFetchForRequestCompression(options?.fetch ?? fetch, model),
2063
2064
  ),
2064
2065
  );
2065
2066
  };
@@ -3203,6 +3204,9 @@ class CodexStreamProcessor {
3203
3204
  this.runtime.sawTerminalEvent = false;
3204
3205
  resetOutputState(this.output);
3205
3206
  this.firstTokenTime = undefined;
3207
+ if (this.requestSetup.requestAbortController.signal.aborted) {
3208
+ this.requestSetup = createRequestSetup(this.options);
3209
+ }
3206
3210
  await scheduler.wait(CODEX_RETRY_DELAY_MS * this.runtime.providerRetryAttempt, {
3207
3211
  signal: this.requestSetup.requestSignal,
3208
3212
  });
@@ -4702,26 +4706,6 @@ async function getOrCreateCodexWebSocketConnection(
4702
4706
  return state.connection;
4703
4707
  }
4704
4708
 
4705
- /**
4706
- * Compress an SSE request body with zstd. Returns `undefined` when
4707
- * compression is disabled or fails, in which case the caller sends the
4708
- * plain JSON string without a `content-encoding` header.
4709
- */
4710
- async function compressCodexRequestBody(bodyJson: string, baseUrl: string): Promise<Uint8Array | undefined> {
4711
- if (!isOfficialCodexApiUrl(baseUrl) || !$flag("PI_CODEX_ZSTD", true)) return undefined;
4712
- try {
4713
- // Off the main thread: SSE sends the full transcript, so multi-MB bodies
4714
- // would otherwise block the event loop for the whole compression.
4715
- return await Bun.zstdCompress(bodyJson, { level: 3 });
4716
- } catch (error) {
4717
- CODEX_DEBUG &&
4718
- logger.debug("[codex] codex request body compression failed", {
4719
- error: error instanceof Error ? error.message : String(error),
4720
- });
4721
- return undefined;
4722
- }
4723
- }
4724
-
4725
4709
  async function openCodexSseEventStream(
4726
4710
  url: string,
4727
4711
  requestHeaders: Record<string, string> | undefined,
@@ -4778,11 +4762,6 @@ async function openCodexSseEventStream(
4778
4762
  }
4779
4763
  }
4780
4764
  };
4781
- const bodyJson = JSON.stringify(body);
4782
- const compressedBody = await compressCodexRequestBody(bodyJson, url);
4783
- if (compressedBody !== undefined) {
4784
- headers.set("content-encoding", "zstd");
4785
- }
4786
4765
  CODEX_DEBUG &&
4787
4766
  logger.debug("[codex] codex request", {
4788
4767
  url,
@@ -4792,11 +4771,12 @@ async function openCodexSseEventStream(
4792
4771
  sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER),
4793
4772
  });
4794
4773
 
4795
- const send = (requestBody: string | Uint8Array): Promise<Response> =>
4796
- fetchWithRetry(url, {
4774
+ let response: Response;
4775
+ try {
4776
+ response = await fetchWithRetry(url, {
4797
4777
  method: "POST",
4798
4778
  headers,
4799
- body: requestBody,
4779
+ body: JSON.stringify(body),
4800
4780
  signal,
4801
4781
  prepareInit: () => {
4802
4782
  // A retried non-2xx attempt leaves its guard armed for the retry-status
@@ -4812,23 +4792,11 @@ async function openCodexSseEventStream(
4812
4792
  fetch: fetchAttempt,
4813
4793
  timeout: false,
4814
4794
  });
4815
- let response: Response;
4816
- try {
4817
- response = await send(compressedBody ?? bodyJson);
4818
- if (compressedBody !== undefined && (response.status === 400 || response.status === 415)) {
4819
- const rejectedStatus = response.status;
4820
- await response.body?.cancel();
4821
- headers.delete("content-encoding");
4822
- CODEX_DEBUG &&
4823
- logger.debug("[codex] retrying request without zstd after encoding rejection", {
4824
- url,
4825
- status: rejectedStatus,
4826
- });
4827
- response = await send(bodyJson);
4828
- }
4829
4795
  } catch (error) {
4830
4796
  clearPreResponseTimeout?.();
4831
- throw error;
4797
+ // Transport rejection after the retry budget: name the host and the cause
4798
+ // instead of surfacing the runtime's bare `getaddrinfo …` text.
4799
+ throw AIError.networkFailureError(error, url);
4832
4800
  }
4833
4801
  CODEX_DEBUG &&
4834
4802
  logger.debug("[codex] codex response", {
@@ -4994,20 +4962,19 @@ export function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
4994
4962
  function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput {
4995
4963
  const messages: ResponseInput = [];
4996
4964
 
4997
- const normalizeToolCallId = (id: string): string => {
4965
+ const normalizeCodexToolCallId = (id: string): string => {
4998
4966
  const sep = id.search(/[\n|]/);
4999
4967
  const [callId, itemId] = sep > 0 ? [id.slice(0, sep), id.slice(sep + 1)] : [id, undefined];
5000
4968
  const normalizedCallId = sanitizeCodexCallId(callId);
5001
- let sanitizedItemId = (itemId ?? Bun.hash(id).toString(36)).replace(/[^a-zA-Z0-9_-]/g, "_");
5002
- if (!sanitizedItemId.startsWith("fc")) {
5003
- sanitizedItemId = `fc_${sanitizedItemId}`;
5004
- }
5005
- let normalizedItemId = sanitizedItemId.length > 64 ? sanitizedItemId.slice(0, 64) : sanitizedItemId;
5006
- normalizedItemId = normalizedItemId.replace(/_+$/, "");
4969
+ const rawItemId = itemId ?? Bun.hash(id).toString(36);
4970
+ const normalizedItemId = normalizeToolCallId(rawItemId.startsWith("fc") ? rawItemId : `fc_${rawItemId}`).replace(
4971
+ /_+$/,
4972
+ "",
4973
+ );
5007
4974
  return `${normalizedCallId}|${normalizedItemId}`;
5008
4975
  };
5009
4976
 
5010
- const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
4977
+ const transformedMessages = transformMessages(context.messages, model, normalizeCodexToolCallId);
5011
4978
  // gpt-5.x reject raw Harmony control-token spellings anywhere in replayed
5012
4979
  // input, including the model's own tool-call arguments (#6913).
5013
4980
  const escapeControlTokens = isHarmonyDialectModel(model);
@@ -34,7 +34,7 @@ import type {
34
34
  ToolChoice,
35
35
  ToolResultMessage,
36
36
  } from "../types";
37
- import { normalizeSystemPrompts, resolveCacheRetention } from "../utils";
37
+ import { normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
38
38
  import { createAbortSourceTracker } from "../utils/abort";
39
39
  import { isDemotedThinking, kStreamingLastParseLen } from "../utils/block-symbols";
40
40
  import { hasVisibleAssistantContent, withReplaySafeStreamRetry } from "../utils/empty-completion-retry";
@@ -48,6 +48,7 @@ import {
48
48
  } from "../utils/idle-iterator";
49
49
  import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
50
50
  import { notifyProviderResponse } from "../utils/provider-response";
51
+ import { wrapFetchForRequestCompression } from "../utils/request-compression";
51
52
  import {
52
53
  adaptSchemaForStrict,
53
54
  findStrictToolSchemaViolation,
@@ -111,6 +112,7 @@ import {
111
112
  type OpenAIRequestSetup,
112
113
  type OpenAIStrictToolsState,
113
114
  parseAzureDeploymentNameMap,
115
+ readOpenRouterRawMetadata,
114
116
  resolveOpenAICompatPolicy,
115
117
  resolveOpenAICompletionsOutputClamp,
116
118
  resolveOpenAIOutputTokenParam,
@@ -127,6 +129,8 @@ import {
127
129
 
128
130
  export { applyOpenRouterRoutingVariant } from "./openai-shared";
129
131
 
132
+ const OPENAI_TOOL_CALL_ID_MAX_LENGTH = 40;
133
+
130
134
  type OpenAICompletionsReasoningField = NonNullable<ResolvedOpenAICompat["reasoningContentField"]>;
131
135
 
132
136
  type ProviderAttributedChatCompletionChunk = ChatCompletionChunk & {
@@ -137,11 +141,15 @@ type OpenAICompletionsChoiceUsage = ChatCompletionChunk.Choice & {
137
141
  usage?: unknown;
138
142
  };
139
143
 
144
+ type OpenAICompletionsRepetitionChoice = ChatCompletionChunk.Choice & {
145
+ stop_reason?: unknown;
146
+ };
147
+
140
148
  type OpenAICompletionsDeltaWithReasoningDetails = ChatCompletionChunk.Choice["delta"] & {
141
149
  reasoning_details?: unknown;
142
150
  };
143
151
 
144
- type GeminiMessageThoughtSignatureField = "thinking_signature" | "thought_signature";
152
+ type GeminiMessageThoughtSignatureField = "thinking_signature" | "thought_signature" | "reasoning_signature";
145
153
 
146
154
  type GeminiMessageThoughtSignature = {
147
155
  field: GeminiMessageThoughtSignatureField;
@@ -163,6 +171,7 @@ const GEMINI_THOUGHT_SIGNATURE_NAMESPACES: readonly GeminiThoughtSignatureNamesp
163
171
  const GEMINI_MESSAGE_THOUGHT_SIGNATURE_FIELDS: readonly GeminiMessageThoughtSignatureField[] = [
164
172
  "thinking_signature",
165
173
  "thought_signature",
174
+ "reasoning_signature",
166
175
  ];
167
176
 
168
177
  function getGeminiThoughtSignatureExtraContent(value: unknown): GeminiThoughtSignatureExtraContent | undefined {
@@ -199,9 +208,10 @@ function parseStoredThoughtSignature(thoughtSignature: string | undefined): unkn
199
208
 
200
209
  // A single tool-call turn on an OpenAI-compatible Gemini wire can carry two
201
210
  // independent signatures: a per-call one (`extra_content.google|vertex` or an
202
- // encrypted `reasoning_details` entry) and a message-level `thinking_signature`
203
- // / `thought_signature`. Both are stashed together on the originating tool
204
- // call's `thoughtSignature` so persistence and replay preserve each field.
211
+ // encrypted `reasoning_details` entry) and a message-level signature
212
+ // (`thinking_signature`, `thought_signature`, or `reasoning_signature`).
213
+ // Both are stashed together on the originating tool call's `thoughtSignature`
214
+ // so persistence and replay preserve each field.
205
215
  // `perCall` holds the raw extra_content object or reasoning detail; `message`
206
216
  // holds the message-level signature in its wire shape.
207
217
  type StoredGeminiSignature = {
@@ -927,8 +937,9 @@ const streamOpenAICompletionsOnce = (
927
937
  headers: headersWithTimeout,
928
938
  body: params,
929
939
  signal: requestSignal,
940
+ maxAttempts: options?.maxHttpAttempts,
930
941
  fetch: wrapFetchForCopilotFallback(
931
- options?.fetch,
942
+ wrapFetchForRequestCompression(options?.fetch ?? fetch, model),
932
943
  model.provider === "github-copilot",
933
944
  resolveCopilotRequestIdentity(options?.headers),
934
945
  copilotCacheKey,
@@ -1082,7 +1093,9 @@ const streamOpenAICompletionsOnce = (
1082
1093
  typeof block.partialArgs === "string" ? parseToolCallArguments(block.partialArgs) : block.partialArgs;
1083
1094
  delete block.partialArgs;
1084
1095
  if (block.streamIndex !== undefined) {
1085
- toolCallBlockByIndex.delete(block.streamIndex);
1096
+ if (toolCallBlockByIndex.get(block.streamIndex) === block) {
1097
+ toolCallBlockByIndex.delete(block.streamIndex);
1098
+ }
1086
1099
  delete block.streamIndex;
1087
1100
  }
1088
1101
  const pendingIndex = pendingToolCallBlocks.indexOf(block);
@@ -1314,6 +1327,8 @@ const streamOpenAICompletionsOnce = (
1314
1327
  let streamFinishedAt: number | undefined;
1315
1328
  let sawUsagePayload = false;
1316
1329
  let awaitTrailingUsageDetails = false;
1330
+ /** Server-declared repetition stop; classified after the finalization sweep. */
1331
+ let repetitionStop: string | undefined;
1317
1332
  const applyUsagePayload = (rawUsage: object): void => {
1318
1333
  output.usage = parseChunkUsage(
1319
1334
  rawUsage,
@@ -1401,6 +1416,15 @@ const streamOpenAICompletionsOnce = (
1401
1416
  }
1402
1417
  }
1403
1418
 
1419
+ // A server-declared repetition stop completes the response, so it takes
1420
+ // the same terminal bookkeeping a `finish_reason` chunk would. The
1421
+ // classification itself is deferred to just before the error checks
1422
+ // below: `output.content` is not final until the block sweep has run.
1423
+ // Latched: a trailing frame without the signal must not clear the one
1424
+ // an earlier chunk carried.
1425
+ repetitionStop ??= detectRepetitionStop(choice);
1426
+ if (repetitionStop) streamFinishedAt ??= Date.now();
1427
+
1404
1428
  if (choice.finish_reason) {
1405
1429
  const finishReasonResult = mapStopReason(choice.finish_reason);
1406
1430
  output.stopReason = finishReasonResult.stopReason;
@@ -1519,9 +1543,17 @@ const streamOpenAICompletionsOnce = (
1519
1543
  // entries are argument-only. Either way, route by array offset so
1520
1544
  // sibling calls stay isolated.
1521
1545
  const unkeyedBatchedArrayEntry = toolCalls.length > 1 && streamIndex === undefined && !toolCall.id;
1522
- let block = streamIndex !== undefined ? toolCallBlockByIndex.get(streamIndex) : undefined;
1523
- if (!block && toolCall.id) {
1524
- block = pendingToolCallBlocks.find(candidate => candidate.id === toolCall.id);
1546
+ // A known id always wins: interleaved continuations resume their own call.
1547
+ // Otherwise reuse the block at this `index` unless the entry opens a new call —
1548
+ // a different id *and* a `function.name`. Some gateways (Gemini) stream every
1549
+ // parallel call at `index: 0` with distinct ids; others mint a fresh id per
1550
+ // argument fragment of one call, but only the opening fragment carries a name.
1551
+ let block = toolCall.id
1552
+ ? pendingToolCallBlocks.find(candidate => candidate.id === toolCall.id)
1553
+ : undefined;
1554
+ const indexedBlock = streamIndex !== undefined ? toolCallBlockByIndex.get(streamIndex) : undefined;
1555
+ if (!block && indexedBlock && (!toolCall.id || !indexedBlock.id || !incomingName)) {
1556
+ block = indexedBlock;
1525
1557
  }
1526
1558
  if (!block && unkeyedBatchedArrayEntry) {
1527
1559
  const offsetBlock = unkeyedBatchBlocks[toolCallOffset];
@@ -1565,7 +1597,8 @@ const streamOpenAICompletionsOnce = (
1565
1597
  finishCurrentBlock(currentBlock);
1566
1598
  }
1567
1599
  currentBlock = block;
1568
- if (streamIndex !== undefined && block.streamIndex === undefined) {
1600
+ // Rebind the index so id-less continuations follow the call that last spoke on it.
1601
+ if (streamIndex !== undefined && toolCallBlockByIndex.get(streamIndex) !== block) {
1569
1602
  block.streamIndex = streamIndex;
1570
1603
  toolCallBlockByIndex.set(streamIndex, block);
1571
1604
  }
@@ -1747,6 +1780,27 @@ const streamOpenAICompletionsOnce = (
1747
1780
  if (output.stopReason === "aborted") {
1748
1781
  throw new AIError.AbortError();
1749
1782
  }
1783
+
1784
+ // Checked before the generic `stopReason === "error"` throw so the flagged
1785
+ // error is the one that reaches `AIError.finalize` — that call rebuilds
1786
+ // `errorId` from the thrown error, so assigning the flag to `output` here
1787
+ // would be overwritten. The partial output is dropped rather than
1788
+ // committed or replayed into the retry; the guard's own loop error does
1789
+ // the same, because degenerate reasoning is replay garbage (#12525).
1790
+ if (repetitionStop) {
1791
+ output.content = [];
1792
+ // The sweep above already closed every block. Clear the handle so the
1793
+ // catch's `finishOpenBlocksOnError` does not re-emit a `thinking_end`
1794
+ // against an index that no longer exists in the emptied content array.
1795
+ currentBlock = undefined;
1796
+ throw AIError.attach(
1797
+ new AIError.ProviderResponseError(`Provider reported a repetition stop: ${repetitionStop}`, {
1798
+ provider: model.provider,
1799
+ kind: "runtime",
1800
+ }),
1801
+ AIError.create(AIError.Flag.ThinkingLoop),
1802
+ );
1803
+ }
1750
1804
  if (output.stopReason === "error") {
1751
1805
  throw new AIError.ProviderResponseError(output.errorMessage || "Provider returned an error stop reason", {
1752
1806
  provider: model.provider,
@@ -1779,7 +1833,7 @@ const streamOpenAICompletionsOnce = (
1779
1833
  output.errorId = result.id;
1780
1834
  output.errorMessage = result.message;
1781
1835
  // Some providers via OpenRouter include extra details here.
1782
- const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
1836
+ const rawMetadata = readOpenRouterRawMetadata(capturedErrorResponse);
1783
1837
  if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
1784
1838
  output.duration = performance.now() - startTime;
1785
1839
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -1814,7 +1868,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (mo
1814
1868
  const resolvedModel = resolveOpenAICompletionsCompat(model);
1815
1869
  return withReplaySafeStreamRetry(resolvedModel, context, options, streamOpenAICompletionsOnce, {
1816
1870
  retryEmptyCompletion: true,
1817
- retryProviderErrors: true,
1871
+ retryProviderErrors: options?.maxHttpAttempts === undefined,
1818
1872
  maxProviderErrorRetries: 1,
1819
1873
  });
1820
1874
  };
@@ -2292,10 +2346,10 @@ export function convertMessages(
2292
2346
  const maxNormalizedToolCallIdLength = compat.requiresMistralToolIds
2293
2347
  ? 9
2294
2348
  : compat.usesOpenAIToolCallIdLimit
2295
- ? 40
2349
+ ? OPENAI_TOOL_CALL_ID_MAX_LENGTH
2296
2350
  : undefined;
2297
2351
  const duplicateToolCallIdSuffixPrefix = compat.requiresMistralToolIds ? "dup" : undefined;
2298
- const normalizeToolCallId = (id: string, source?: AssistantMessage): string => {
2352
+ const normalizeCompletionsToolCallId = (id: string, source?: AssistantMessage): string => {
2299
2353
  if (compat.requiresMistralToolIds) return normalizeMistralToolId(id, true);
2300
2354
 
2301
2355
  const isSameModelSource =
@@ -2308,16 +2362,17 @@ export function convertMessages(
2308
2362
  // Chat Completions IDs are provider-issued opaque correlation tokens.
2309
2363
  if (!isSameModelSource && id.includes("|")) {
2310
2364
  const [callId] = id.split("|");
2311
- return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40);
2365
+ return normalizeToolCallId(callId, OPENAI_TOOL_CALL_ID_MAX_LENGTH);
2312
2366
  }
2313
2367
 
2314
- if (compat.usesOpenAIToolCallIdLimit) return id.length > 40 ? id.slice(0, 40) : id;
2368
+ if (compat.usesOpenAIToolCallIdLimit)
2369
+ return id.length > OPENAI_TOOL_CALL_ID_MAX_LENGTH ? id.slice(0, OPENAI_TOOL_CALL_ID_MAX_LENGTH) : id;
2315
2370
  return id;
2316
2371
  };
2317
2372
  const transformedMessages = transformMessages(
2318
2373
  context.messages,
2319
2374
  model,
2320
- (id, _target, source) => normalizeToolCallId(id, source),
2375
+ (id, _target, source) => normalizeCompletionsToolCallId(id, source),
2321
2376
  maxNormalizedToolCallIdLength,
2322
2377
  duplicateToolCallIdSuffixPrefix,
2323
2378
  compat,
@@ -2350,7 +2405,7 @@ export function convertMessages(
2350
2405
  };
2351
2406
 
2352
2407
  const ensureToolCallId = (rawId: string, seed: string, source?: AssistantMessage): string => {
2353
- const normalized = normalizeToolCallId(rawId, source);
2408
+ const normalized = normalizeCompletionsToolCallId(rawId, source);
2354
2409
  if (normalized.trim().length > 0) return normalized;
2355
2410
  return generateFallbackToolCallId(seed);
2356
2411
  };
@@ -2880,6 +2935,36 @@ function convertTools(
2880
2935
  const EMPTY_OLLAMA_LENGTH_COMPLETION_MESSAGE =
2881
2936
  "Model returned no content: prompt filled the context window; raise Ollama num_ctx or shorten the prompt.";
2882
2937
 
2938
+ /**
2939
+ * Detect a server-declared repetition stop on a streamed choice.
2940
+ *
2941
+ * vLLM and other OpenAI-compatible servers report a generation that degenerated
2942
+ * into repetition as a terminal signal rather than a content pattern OMP can
2943
+ * see: `finish_reason: "repetition"`, and/or `stop_reason: "repetition_detected"`.
2944
+ * The `stop_reason` form arrives alongside an ordinary `finish_reason: "stop"`,
2945
+ * or with no `finish_reason` at all, so it has to be read independently of it —
2946
+ * otherwise the signal is either masked by the clean finish or never seen.
2947
+ *
2948
+ * Returns the raw values for the diagnostic message, or `undefined` when the
2949
+ * choice carries no repetition signal.
2950
+ */
2951
+ function detectRepetitionStop(choice: ChatCompletionChunk.Choice): string | undefined {
2952
+ const finishReason: string | null | undefined = choice.finish_reason;
2953
+ const stopReason = (choice as OpenAICompletionsRepetitionChoice).stop_reason;
2954
+ const namesRepetition = (value: string): boolean => {
2955
+ const lower = value.toLowerCase();
2956
+ return lower === "repetition" || lower === "repetition_detected";
2957
+ };
2958
+ const flagged =
2959
+ (typeof finishReason === "string" && namesRepetition(finishReason)) ||
2960
+ (typeof stopReason === "string" && namesRepetition(stopReason));
2961
+ if (!flagged) return undefined;
2962
+ const parts: string[] = [];
2963
+ if (typeof finishReason === "string" && finishReason.length > 0) parts.push(`finish_reason=${finishReason}`);
2964
+ if (typeof stopReason === "string" && stopReason.length > 0) parts.push(`stop_reason=${stopReason}`);
2965
+ return parts.join(" ") || "repetition";
2966
+ }
2967
+
2883
2968
  function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
2884
2969
  stopReason: StopReason;
2885
2970
  errorMessage?: string;
@@ -2921,6 +3006,19 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | str
2921
3006
  stopReason: "error",
2922
3007
  errorMessage: "Provider returned error finish_reason: insufficient_system_resource",
2923
3008
  };
3009
+ case "unknown":
3010
+ case "other":
3011
+ case "unspecified":
3012
+ case "finish_reason_unspecified":
3013
+ // The provider ended the stream without saying why (a dropped
3014
+ // connection behind a gateway, a backend that lost the stop code).
3015
+ // Pinning it as a terminal error strands a turn that a resample
3016
+ // fixes. Word the message to match the transient-transport retry
3017
+ // pattern; the retry budget bounds the replay.
3018
+ return {
3019
+ stopReason: "error",
3020
+ errorMessage: `Provider returned error finish_reason: ${normalized}`,
3021
+ };
2924
3022
  default:
2925
3023
  return {
2926
3024
  stopReason: "error",
@@ -39,6 +39,7 @@ import {
39
39
  } from "../utils/idle-iterator";
40
40
  import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
41
41
  import { notifyProviderResponse } from "../utils/provider-response";
42
+ import { wrapFetchForRequestCompression } from "../utils/request-compression";
42
43
  import { waitForRetry } from "../utils/retry-wait";
43
44
  import {
44
45
  adaptSchemaForStrict,
@@ -103,6 +104,7 @@ import {
103
104
  isCompiledGrammarTooLargeStrictError,
104
105
  isOpenAIResponsesProgressEvent,
105
106
  isStrictToolsDisabledForScope,
107
+ readOpenRouterRawMetadata,
106
108
  type OpenAIPromptCacheOptions,
107
109
  type OpenAIStrictToolsScope,
108
110
  type OpenAIStrictToolsState,
@@ -496,6 +498,27 @@ function getOpenAIResponsesProviderSessionState(
496
498
  return created;
497
499
  }
498
500
 
501
+ /**
502
+ * Whether the next request for `model` replays native Responses history items
503
+ * (encrypted reasoning, item ids), as {@link buildParams} decides it. A session
504
+ * is cold, rebuilding turns from message content, from the first request on a
505
+ * connection-bound host (`compat.connectionBoundNativeHistory`, GitHub Copilot)
506
+ * and after `close()` on any host, until a response succeeds. Without session
507
+ * state every request replays.
508
+ */
509
+ export function openAIResponsesReplaysNativeHistory(
510
+ model: Pick<Model, "api" | "provider" | "compat"> | null | undefined,
511
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
512
+ ): boolean {
513
+ if (model?.api !== "openai-responses" || !providerSessionState) return true;
514
+ const state = providerSessionState.get(`${OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX}${model.provider}`) as
515
+ | OpenAIResponsesProviderSessionState
516
+ | undefined;
517
+ if (state) return state.nativeHistoryReplayWarmed;
518
+ const compat = model.compat;
519
+ return !(compat && "connectionBoundNativeHistory" in compat && compat.connectionBoundNativeHistory);
520
+ }
521
+
499
522
  /** Host per-provider storage defaults; see {@link configureProviderStoreResponses}. */
500
523
  let configuredProviderStoreResponses: Readonly<Record<string, boolean>> = {};
501
524
 
@@ -872,7 +895,7 @@ const streamOpenAIResponsesOnce = (
872
895
  body: requestParams,
873
896
  signal: requestSignal,
874
897
  fetch: wrapFetchForCopilotFallback(
875
- options?.fetch,
898
+ wrapFetchForRequestCompression(options?.fetch ?? fetch, model),
876
899
  model.provider === "github-copilot",
877
900
  resolveCopilotRequestIdentity(options?.headers),
878
901
  copilotCacheKey,
@@ -1355,7 +1378,7 @@ const streamOpenAIResponsesOnce = (
1355
1378
  output.requestBodyReadTimeoutFullReplay = true;
1356
1379
  }
1357
1380
  // Some providers via OpenRouter include extra details here.
1358
- const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
1381
+ const rawMetadata = readOpenRouterRawMetadata(capturedErrorResponse);
1359
1382
  if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
1360
1383
  output.duration = performance.now() - startTime;
1361
1384
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;