@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -1,6 +1,7 @@
1
1
  import type { AdapterEvent, OcxParsedRequest } from "../types";
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
3
  import type { RequestExecutionBudget } from "../lib/request-execution-budget";
4
+ import type { AttemptRecoveryKind } from "../usage/log";
4
5
  import type { AdapterTierMetadata } from "../providers/fastwire";
5
6
 
6
7
  /** Metadata about the caller's incoming request, for auth-forwarding adapters. */
@@ -20,6 +21,16 @@ export interface IncomingMeta {
20
21
  * the anthropic and openai-chat adapters; others ignore it.
21
22
  */
22
23
  imageTierBias?: number;
24
+ /**
25
+ * The enclosing request's send budget, for adapters that own their upstream transport.
26
+ *
27
+ * A `runTurn` adapter never receives an `AdapterFetchContext`, so the budget that bounds every
28
+ * other leg could not reach it: Cursor re-sends a whole turn up to three times inside one
29
+ * adapter call, and the request cap counted that as one send. Optional, and absent means
30
+ * unlimited, because adapter unit tests build a meta with neither a budget nor a request
31
+ * behind it (#4546).
32
+ */
33
+ sendBudget?: RequestExecutionBudget;
23
34
  }
24
35
 
25
36
  export interface ProviderAdapter {
@@ -147,6 +158,16 @@ export interface AdapterFetchContext {
147
158
  * adapter entry as one send is how a nested 3x3 ladder stayed invisible to a request cap.
148
159
  */
149
160
  sendBudget?: RequestExecutionBudget;
161
+ /**
162
+ * Observes every physical upstream send this adapter makes, including its own inner retries.
163
+ *
164
+ * `ordinal` counts from 1 within this fetch call, so a caller that already recorded the entry
165
+ * send records only ordinals above 1 and an adapter that never retries internally logs exactly
166
+ * what it logs today. Kiro and Cursor were unpinnable without this: they report one send per
167
+ * adapter call however many requests they actually made, so their inner ladders were invisible
168
+ * to `sendCount` and no regression could assert a count for them (#4546).
169
+ */
170
+ onPhysicalSend?: (send: { ordinal: number; recovery?: AttemptRecoveryKind }) => void;
150
171
  }
151
172
 
152
173
  /**
@@ -1,6 +1,8 @@
1
1
  import type { CursorRunRequest, CursorServerMessage } from "./types";
2
2
  import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport";
3
- import { abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry";
3
+ import type { RequestExecutionBudget } from "../../lib/request-execution-budget";
4
+ import type { AttemptRecoveryKind } from "../../usage/log";
5
+ import { SendBudgetExhaustedError, abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry";
4
6
  import { debugProviderDiagnostic } from "../../lib/debug";
5
7
  import { isCursorRootEnvelopeError, safeCursorErrorMessage } from "./cursor-errors";
6
8
 
@@ -11,6 +13,27 @@ export const CURSOR_RETRY_ATTEMPTS = 3;
11
13
  export const CURSOR_RETRY_BASE_MS = 250;
12
14
  export const CURSOR_RETRY_MAX_MS = 2_000;
13
15
 
16
+ /**
17
+ * Fixed identity for the Cursor upstream in the request budget's target ledger. A literal, not
18
+ * anything derived from the turn: the ledger is read back in diagnostics, so it must not become
19
+ * a place where a session or credential identity leaks.
20
+ */
21
+ export const CURSOR_BUDGET_TARGET_KEY = "cursor";
22
+
23
+ /**
24
+ * How one Cursor turn participates in the enclosing logical request (#4546).
25
+ *
26
+ * Both fields are optional and the whole object defaults to empty, which is what keeps a
27
+ * context-free unit call unlimited: this transport is exercised directly by tests that build no
28
+ * request at all, and a mandatory budget would have made every one of them a budget test.
29
+ */
30
+ export interface CursorTurnExecutionOptions {
31
+ /** Absent means unlimited; present means every retry is a physical send the request pays for. */
32
+ sendBudget?: RequestExecutionBudget;
33
+ /** Observes each physical run request; `ordinal` counts from 1 within this turn. */
34
+ onPhysicalSend?: (send: { ordinal: number; recovery?: AttemptRecoveryKind }) => void;
35
+ }
36
+
14
37
  /**
15
38
  * True only for clearly transient failures that occur BEFORE the run request is committed to the
16
39
  * wire (connection refused/reset/timeout, immediate HTTP/2 GOAWAY, gRPC/Connect "unavailable").
@@ -66,6 +89,11 @@ function requestUncommitted(transport: CursorTransport): boolean {
66
89
  * - the failing transport reports the run request was not committed to the wire,
67
90
  * - the error is a transient pre-commit failure.
68
91
  * Otherwise the error propagates (the adapter maps it to a user-facing message).
92
+ *
93
+ * `execution` carries the enclosing request's send budget. Each attempt here is a real re-send
94
+ * of the whole turn, so an outer cap that counted one adapter entry counted at most a third of
95
+ * what went upstream; when a budget is present every attempt is admitted against it and an
96
+ * exhausted request stops before opening another transport (#4546).
69
97
  */
70
98
  export async function runCursorTurnWithRetry(
71
99
  makeTransport: (input: CursorTransportFactoryInput) => CursorTransport,
@@ -73,9 +101,26 @@ export async function runCursorTurnWithRetry(
73
101
  request: CursorRunRequest,
74
102
  signal: AbortSignal | undefined,
75
103
  onEvent: (message: CursorServerMessage, transport: CursorTransport) => void,
104
+ execution: CursorTurnExecutionOptions = {},
76
105
  ): Promise<void> {
77
106
  for (let attempt = 0; ; attempt++) {
78
107
  if (signal?.aborted) throw abortError(signal);
108
+ // Admitted before the transport is built: a refused send must not open a connection, and
109
+ // the refusal must reach the adapter as the typed exhaustion rather than as a run failure
110
+ // that the retry predicate below could read as transient.
111
+ const decision = execution.sendBudget?.reserveDispatch({
112
+ sendClass: "transient",
113
+ targetKey: CURSOR_BUDGET_TARGET_KEY,
114
+ });
115
+ if (decision && (!decision.allowed || !decision.permit.use())) {
116
+ throw new SendBudgetExhaustedError(CURSOR_BUDGET_TARGET_KEY);
117
+ }
118
+ execution.onPhysicalSend?.({
119
+ ordinal: attempt + 1,
120
+ // Cursor retries only pre-commit transport failures, so every retry send is the
121
+ // connection-reset class; there is no re-send of a turn the server may have accepted.
122
+ ...(attempt > 0 ? { recovery: "connection-reset" as const } : {}),
123
+ });
79
124
  const transport = makeTransport(input);
80
125
  let emittedAny = false;
81
126
  let closed = false;
@@ -403,6 +403,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
403
403
  }
404
404
  }
405
405
  },
406
+ // Cursor's retry ladder re-sends the WHOLE turn, so each attempt is a physical send
407
+ // the enclosing request pays for. A meta without a budget -- every adapter unit test,
408
+ // and any caller predating this -- keeps the adapter's own three attempts (#4546).
409
+ incoming.sendBudget ? { sendBudget: incoming.sendBudget } : {},
406
410
  );
407
411
  };
408
412
 
@@ -45,6 +45,10 @@ import {
45
45
  type KiroWireClient,
46
46
  } from "./wire";
47
47
 
48
+ /** The physical-send observer an `AdapterFetchContext` may carry, and the record it receives. */
49
+ type KiroPhysicalSendObserver = NonNullable<AdapterFetchContext["onPhysicalSend"]>;
50
+ type KiroPhysicalSend = Parameters<KiroPhysicalSendObserver>[0];
51
+
48
52
  // Adapter
49
53
  export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter {
50
54
  // Per-request closure (resolveAdapter builds a fresh adapter per request — server.ts:440 — so this
@@ -62,6 +66,25 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
62
66
  // Captured the same way as the abort signal, because the text-fallback rebuild below runs
63
67
  // outside the fetchResponse frame and used to construct a context without either (#4546).
64
68
  let requestSendBudget: RequestExecutionBudget | undefined;
69
+ // Captured for the same reason, and needed for the same leg to be COUNTABLE rather than merely
70
+ // bounded: the rebuild's sends were paid for out of the request budget but reported by nobody,
71
+ // so no regression could pin how many requests one Kiro turn actually makes.
72
+ let requestOnPhysicalSend: KiroPhysicalSendObserver | undefined;
73
+ // One ordinal sequence across the whole turn. `fetchKiroWithRetry` numbers from 1 inside each
74
+ // call, and the caller reads ordinal 1 as the send it already recorded itself; forwarding the
75
+ // rebuild's raw ordinals would therefore drop its first send — the very send that makes the
76
+ // fallback a second request rather than a continuation of the first.
77
+ let physicalSendsObserved = 0;
78
+ const forwardPhysicalSend = (
79
+ send: KiroPhysicalSend,
80
+ ordinalBase: number,
81
+ defaultRecovery?: KiroPhysicalSend["recovery"],
82
+ ): void => {
83
+ const ordinal = ordinalBase + send.ordinal;
84
+ if (ordinal > physicalSendsObserved) physicalSendsObserved = ordinal;
85
+ const recovery = send.recovery ?? defaultRecovery;
86
+ requestOnPhysicalSend?.({ ordinal, ...(recovery ? { recovery } : {}) });
87
+ };
65
88
 
66
89
  const build = async (
67
90
  parsed: OcxParsedRequest,
@@ -208,6 +231,9 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
208
231
  retryBodyReservation.commitRetained();
209
232
  retryBodyRetained = true;
210
233
  budget.releaseRetained(retryBodyUpperBound - retryBodyBytes, { kind: "request_copies" });
234
+ // Fixed before the rebuild dispatches, so the leg's ordinals continue the first attempt's
235
+ // sequence even though this call's own counter restarts at 1.
236
+ const fallbackOrdinalBase = physicalSendsObserved;
211
237
  const response = await fetchKiroWithRetry(retry.request, {
212
238
  abortSignal: requestAbortSignal,
213
239
  returnRawErrors: true,
@@ -215,6 +241,12 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
215
241
  // The text-fallback rebuild used to construct a fresh context and drop the budget,
216
242
  // so everything after the first send escaped the per-request cap.
217
243
  ...(requestSendBudget ? { sendBudget: requestSendBudget } : {}),
244
+ // And reported nothing, so the sends it paid for were invisible. Its own first send is
245
+ // the completion retry itself: the first attempt produced progress without a final
246
+ // answer, which is the same recovery class the generic empty-completion guard records.
247
+ ...(requestOnPhysicalSend
248
+ ? { onPhysicalSend: (send: KiroPhysicalSend) => forwardPhysicalSend(send, fallbackOrdinalBase, "empty-completion") }
249
+ : {}),
218
250
  });
219
251
  return {
220
252
  response,
@@ -286,7 +318,16 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
286
318
  // both the first Kiro request and its one allowed completion retry.
287
319
  if (ctx?.abortSignal) requestAbortSignal = ctx.abortSignal;
288
320
  if (ctx?.sendBudget) requestSendBudget = ctx.sendBudget;
289
- return fetchKiroWithRetry(request, ctx);
321
+ if (ctx?.onPhysicalSend) requestOnPhysicalSend = ctx.onPhysicalSend;
322
+ // Reset per fetch call, because `ordinal` is defined within one call and the caller records
323
+ // ordinal 1 of each new attempt itself. The text fallback that follows this attempt then
324
+ // continues THIS attempt's sequence rather than an earlier one's.
325
+ physicalSendsObserved = 0;
326
+ // Routed through the same forwarder as the fallback so both legs share one ordinal
327
+ // sequence; a context without an observer is passed through untouched.
328
+ return fetchKiroWithRetry(request, requestOnPhysicalSend
329
+ ? { ...ctx, onPhysicalSend: (send: KiroPhysicalSend) => forwardPhysicalSend(send, 0) }
330
+ : ctx);
290
331
  },
291
332
 
292
333
  formatErrorBody(status: number, headers: Headers, payloadText: string): string {
@@ -1,4 +1,5 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
+ import type { AttemptRecoveryKind } from "../usage/log";
2
3
  import { classifyKiroHttpError, safeKiroHttpErrorMessage } from "./kiro-errors";
3
4
  import { normalizeUpstreamHttpErrorResponse } from "./upstream-http-error";
4
5
  import { readBoundedResponseBody } from "../lib/bounded-body";
@@ -159,6 +160,7 @@ async function fetchWithResetRecovery(
159
160
  url: string,
160
161
  ctx: AdapterFetchContext,
161
162
  timeoutMs: number,
163
+ notePhysicalSend: (reset: boolean) => void,
162
164
  ): Promise<Response> {
163
165
  let lastError: unknown;
164
166
  for (let attempt = 0; attempt < RESET_ATTEMPTS; attempt++) {
@@ -170,6 +172,9 @@ async function fetchWithResetRecovery(
170
172
  if (decision && (!decision.allowed || !decision.permit.use())) {
171
173
  throw new SendBudgetExhaustedError(url);
172
174
  }
175
+ // Reported after admission and before dispatch, so a refused send is never counted and an
176
+ // admitted one is counted exactly once whichever way the fetch below settles.
177
+ notePhysicalSend(attempt > 0);
173
178
  try {
174
179
  const headers = new Headers(request.headers);
175
180
  const recovered = attempt > 0;
@@ -252,14 +257,15 @@ async function fetchKiroAttempt(
252
257
  request: AdapterRequest,
253
258
  ctx: AdapterFetchContext,
254
259
  timeoutMs: number,
260
+ notePhysicalSend: (reset: boolean) => void,
255
261
  ): Promise<Response> {
256
262
  const legacy = legacyUrl(request.url);
257
263
  let response: Response;
258
264
  try {
259
- response = await fetchWithResetRecovery(request, request.url, ctx, timeoutMs);
265
+ response = await fetchWithResetRecovery(request, request.url, ctx, timeoutMs, notePhysicalSend);
260
266
  } catch (error) {
261
267
  if (!legacy || !endpointConnectFailure(error)) throw error;
262
- return fetchWithResetRecovery(request, legacy, ctx, timeoutMs);
268
+ return fetchWithResetRecovery(request, legacy, ctx, timeoutMs, notePhysicalSend);
263
269
  }
264
270
 
265
271
  if (legacy && !response.ok) {
@@ -267,7 +273,7 @@ async function fetchKiroAttempt(
267
273
  response = inspected.response;
268
274
  if (inspected.fallback) {
269
275
  cancelResponseBodyBestEffort(response);
270
- response = await fetchWithResetRecovery(request, legacy, ctx, timeoutMs);
276
+ response = await fetchWithResetRecovery(request, legacy, ctx, timeoutMs, notePhysicalSend);
271
277
  }
272
278
  }
273
279
  return response;
@@ -281,12 +287,25 @@ async function fetchKiroAttempt(
281
287
  export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
282
288
  const timeoutMs = ctx.timeoutMs ?? 200_000;
283
289
  let probeToken: symbol | undefined;
290
+ // One ordinal sequence for the whole call, across the throttle loop, the endpoint fallback
291
+ // and the reset ladder nested inside it. The caller records ordinal 1 itself, so this is what
292
+ // turns "one adapter call" back into the physical count the request actually made.
293
+ let physicalSends = 0;
294
+ let throttleRound = 0;
295
+ const notePhysicalSend = (reset: boolean): void => {
296
+ physicalSends += 1;
297
+ const recovery: AttemptRecoveryKind | undefined = reset
298
+ ? "connection-reset"
299
+ : throttleRound > 0 ? "rate-limit-429" : undefined;
300
+ ctx.onPhysicalSend?.({ ordinal: physicalSends, ...(recovery ? { recovery } : {}) });
301
+ };
284
302
  try {
285
303
  for (let attempt = 0; attempt < THROTTLE_ATTEMPTS; attempt++) {
304
+ throttleRound = attempt;
286
305
  if (!probeToken) probeToken = await enterKiroThrottleGate(ctx.abortSignal);
287
306
  else await waitForKiroCooldown(ctx.abortSignal);
288
307
 
289
- const response = await fetchKiroAttempt(request, ctx, timeoutMs);
308
+ const response = await fetchKiroAttempt(request, ctx, timeoutMs, notePhysicalSend);
290
309
  const throttle = await inspectKiroThrottle(response, ctx.abortSignal);
291
310
  if (!throttle || !throttle.transient) {
292
311
  releaseKiroThrottleProbe(probeToken);
@@ -0,0 +1,116 @@
1
+ import { isCyberPolicyCode } from "../../lib/errors";
2
+ import { redactSecretString } from "../../lib/redact";
3
+ import type { AdapterEvent, OcxUsage } from "../../types";
4
+
5
+ // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
6
+ // loop.ts only appends a suffix to "Provider error N" when the adapter exposes
7
+ // formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
8
+ // validation, "This model only supports single tool-calls at once!", etc.) were reduced
9
+ // to a bare status code. JSON-only extraction: recognized string fields are returned,
10
+ // HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
11
+ export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
12
+ let parsed: unknown;
13
+ try {
14
+ parsed = JSON.parse(payloadText);
15
+ } catch {
16
+ return "";
17
+ }
18
+ const detail = extractErrorDetail(parsed);
19
+ if (!detail) return "";
20
+ return redactSecretString(detail).slice(0, 400);
21
+ }
22
+
23
+ function extractErrorDetail(parsed: unknown): string | undefined {
24
+ if (typeof parsed === "string") return parsed.trim() || undefined;
25
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
26
+ const obj = parsed as Record<string, unknown>;
27
+ const err = obj.error;
28
+ if (typeof err === "string" && err.trim()) return err.trim();
29
+ if (err !== null && typeof err === "object" && !Array.isArray(err)) {
30
+ const msg = (err as Record<string, unknown>).message;
31
+ if (typeof msg === "string" && msg.trim()) return msg.trim();
32
+ }
33
+ const det = obj.detail;
34
+ if (typeof det === "string" && det.trim()) return det.trim();
35
+ if (Array.isArray(det)) {
36
+ const msgs = det
37
+ .map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
38
+ ? ((item as Record<string, unknown>).msg as string).trim()
39
+ : ""))
40
+ .filter(m => m.length > 0);
41
+ if (msgs.length > 0) return msgs.join("; ");
42
+ }
43
+ if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
44
+ if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
45
+ return undefined;
46
+ }
47
+
48
+ export function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
49
+ if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
50
+ const data = json.data;
51
+ return data !== null && typeof data === "object" && !Array.isArray(data)
52
+ ? data as Record<string, unknown>
53
+ : json;
54
+ }
55
+
56
+ export interface OpenAIChatError {
57
+ message?: unknown;
58
+ code?: unknown;
59
+ type?: unknown;
60
+ status?: unknown;
61
+ metadata?: unknown;
62
+ }
63
+
64
+ export function safeUpstreamRequestId(metadata: unknown): string | undefined {
65
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
66
+ const record = metadata as Record<string, unknown>;
67
+ const value = record.request_id ?? record.requestId;
68
+ if (typeof value !== "string") return undefined;
69
+ const requestId = value.trim();
70
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
71
+ && redactSecretString(requestId) === requestId
72
+ ? requestId
73
+ : undefined;
74
+ }
75
+
76
+ export function upstreamErrorEvent(
77
+ error: unknown,
78
+ usage?: OcxUsage,
79
+ ): Extract<AdapterEvent, { type: "error" }> {
80
+ const details = error !== null && typeof error === "object" && !Array.isArray(error)
81
+ ? error as OpenAIChatError
82
+ : undefined;
83
+ const rawMessage = typeof error === "string"
84
+ ? error.trim() || "upstream error"
85
+ : typeof details?.message === "string" ? details.message : "upstream error";
86
+ const safeMessage = redactSecretString(rawMessage);
87
+ const requestId = safeUpstreamRequestId(details?.metadata);
88
+ const message = requestId !== undefined && !safeMessage.includes(requestId)
89
+ ? `${safeMessage} (request ID: ${requestId})`
90
+ : safeMessage;
91
+ const code = typeof details?.code === "string"
92
+ ? details.code
93
+ : typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code)
94
+ ? String(details.code)
95
+ : undefined;
96
+ const errorType = typeof details?.type === "string" ? details.type : undefined;
97
+ const codeStatus = typeof details?.code === "number"
98
+ && Number.isInteger(details.code)
99
+ && details.code >= 100
100
+ && details.code <= 599
101
+ ? details.code
102
+ : undefined;
103
+ const status = isCyberPolicyCode(code)
104
+ ? 400
105
+ : typeof details?.status === "number" && Number.isInteger(details.status)
106
+ ? details.status
107
+ : codeStatus;
108
+ return {
109
+ type: "error",
110
+ message,
111
+ ...(usage !== undefined ? { usage } : {}),
112
+ ...(code !== undefined ? { code } : {}),
113
+ ...(errorType !== undefined ? { errorType } : {}),
114
+ ...(status !== undefined ? { status } : {}),
115
+ };
116
+ }