@bitkyc08/opencodex 2.88.0 → 2.89.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 (205) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -1,5 +1,8 @@
1
1
  import { bridgeToResponsesSSE, formatErrorResponse } from "../../bridge";
2
2
  import { OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE } from "../../adapters/ollama-native";
3
+ import { isThroughputLimitText } from "../../claude/outbound";
4
+ import { isDefiniteContextOverflow } from "../../combos/failover";
5
+ import { classifyError } from "../../lib/errors";
3
6
  import type { TranslatorBudget } from "../../lib/translator-budget";
4
7
  import type { AdapterEvent } from "../../types";
5
8
 
@@ -32,8 +35,11 @@ export function jsonContextOverflowResponse(message = PROVIDER_INPUT_TOO_LARGE_M
32
35
  }, { status: 413, headers: { "Cache-Control": "no-store" } });
33
36
  }
34
37
 
35
- async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEvent> {
36
- yield {
38
+ /** The terminal error event every context overflow is reported as, streaming or in-stream. */
39
+ export function contextOverflowEvent(
40
+ message = PROVIDER_INPUT_TOO_LARGE_MESSAGE,
41
+ ): Extract<AdapterEvent, { type: "error" }> {
42
+ return {
37
43
  type: "error",
38
44
  message,
39
45
  status: 413,
@@ -43,6 +49,139 @@ async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEv
43
49
  };
44
50
  }
45
51
 
52
+ async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEvent> {
53
+ yield contextOverflowEvent(message);
54
+ }
55
+
56
+ /**
57
+ * Whether a web-search or image-bridge failure is the provider refusing an oversized input.
58
+ *
59
+ * An HTTP 413 is classified by status alone, exactly as the main path does. A 400/422 needs a
60
+ * definite overflow phrase in the provider message (the rule combo failover already trusts), so a
61
+ * plain bad request stays what it was, and rate-window wording ("too many tokens per minute")
62
+ * never reads as an overflow: compacting does not fix a throughput limit. An adapter that already
63
+ * classified its own failure as `context_length_exceeded` keeps that verdict whatever status it
64
+ * rode on. The local translator limit is excluded: compacting cannot shrink a buffer this proxy owns.
65
+ */
66
+ export function isSidecarContextOverflow(
67
+ status: number | undefined,
68
+ message: string,
69
+ code?: string | null,
70
+ ): boolean {
71
+ if (code === "translation_buffer_limit" || code === "rate_limit_exceeded") return false;
72
+ if (code === "context_length_exceeded") return true;
73
+ if (status === 413) return true;
74
+ return (status === 400 || status === 422) && !isThroughputLimitText(message)
75
+ && isDefiniteContextOverflow(status, message);
76
+ }
77
+
78
+ /**
79
+ * The in-stream terminal for a sidecar overflow. A bare 413 carries no provider wording worth
80
+ * keeping and gets the proxy-owned message. A worded verdict keeps its own status and message:
81
+ * that is the evidence combo failover reads to try a larger-context target, and replacing it
82
+ * would end the chain at the first target that was merely too small.
83
+ */
84
+ export function sidecarContextOverflowEvent(
85
+ status: number | undefined,
86
+ message: string,
87
+ ): Extract<AdapterEvent, { type: "error" }> {
88
+ return status === undefined || !message || (status === 413 && !isDefiniteContextOverflow(413, message))
89
+ ? contextOverflowEvent()
90
+ : { ...contextOverflowEvent(message), status };
91
+ }
92
+
93
+ /** One sidecar loop failure, with what the loop knew when it raised it. */
94
+ export interface SidecarLoopFailure {
95
+ status: number;
96
+ message: string;
97
+ event?: Extract<AdapterEvent, { type: "error" }>;
98
+ /** The provider's own `error.code`, read from a bounded display-safe body. */
99
+ upstreamCode?: string;
100
+ /** The failed response was marked non-replayable: the provider may already have run the turn. */
101
+ nonReplayable?: boolean;
102
+ }
103
+
104
+ /**
105
+ * The overflow terminal a sidecar loop failure should end with, or `undefined` to keep the
106
+ * failure as it is. A non-replayable failure keeps its verdict: an overflow would invite a
107
+ * compacted resend of a turn the provider may already have run. An adapter event marked
108
+ * `retryable: false` under any other code is that adapter's own verdict and is not reread.
109
+ */
110
+ export function sidecarLoopOverflowEvent(
111
+ failure: SidecarLoopFailure,
112
+ ): Extract<AdapterEvent, { type: "error" }> | undefined {
113
+ if (failure.nonReplayable) return undefined;
114
+ const code = failure.event?.code ?? failure.upstreamCode;
115
+ if (failure.event?.retryable === false && code !== "context_length_exceeded") return undefined;
116
+ if (!isSidecarContextOverflow(failure.status, failure.message, code)) return undefined;
117
+ return sidecarContextOverflowEvent(failure.event?.status ?? failure.status, failure.message);
118
+ }
119
+
120
+ /**
121
+ * The in-stream terminal for a sidecar loop failure that carries no adapter event, or
122
+ * `undefined` to keep the loop's existing generic event. An overflow ends as the overflow
123
+ * terminal. A failure this classifier deliberately kept (non-replayable, or an upstream
124
+ * `rate_limit_exceeded`) gets an explicit code whenever the bridge's wording heuristics would
125
+ * otherwise rename it `context_length_exceeded` and undo that verdict on the wire.
126
+ */
127
+ export function sidecarLoopTerminalEvent(
128
+ failure: SidecarLoopFailure,
129
+ ): Extract<AdapterEvent, { type: "error" }> | undefined {
130
+ const overflow = sidecarLoopOverflowEvent(failure);
131
+ if (overflow || failure.event) return overflow;
132
+ if (classifyError(failure.status, "upstream_error", failure.message).code !== "context_length_exceeded") return undefined;
133
+ const rateLimited = failure.upstreamCode === "rate_limit_exceeded";
134
+ return {
135
+ type: "error",
136
+ status: failure.status,
137
+ message: failure.message,
138
+ errorType: rateLimited ? "rate_limit_error" : "upstream_error",
139
+ code: failure.upstreamCode ?? "upstream_error",
140
+ ...(failure.nonReplayable ? { retryable: false } : {}),
141
+ };
142
+ }
143
+
144
+ /** Read a provider's `error.code` from an already bounded, display-safe error body. */
145
+ export function upstreamErrorCode(text: string): string | undefined {
146
+ try {
147
+ const code = (JSON.parse(text) as { error?: { code?: unknown } } | null)?.error?.code;
148
+ return typeof code === "string" && code.length <= 128 ? code : undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ const sidecarOverflowResponses = new WeakSet<Response>();
155
+
156
+ /** Tag a loop's first-iteration JSON failure that the sidecar boundary should map to an overflow. */
157
+ export function markSidecarContextOverflow(response: Response, failure: SidecarLoopFailure): Response {
158
+ if (sidecarLoopOverflowEvent(failure)) sidecarOverflowResponses.add(response);
159
+ return response;
160
+ }
161
+
162
+ /**
163
+ * Map a sidecar loop's pre-stream HTTP failure onto the main path's overflow contract (#7316).
164
+ *
165
+ * Both loops answer a failed first iteration with their own small JSON error, so the overflow
166
+ * would otherwise reach Codex as a bare non-2xx that it resends unchanged through its retry
167
+ * budget. The loop classified the failure while it still had the provider's status, wording,
168
+ * code and replay verdict, and tagged the response; anything untagged is returned untouched.
169
+ * Combo attempts do not come through here: the combo layer reads the raw failure for its hop
170
+ * decision and maps a final 413 itself. A failure answered with `x-should-retry: false` keeps
171
+ * its status and header, so that contract never turns into an invitation to resend.
172
+ */
173
+ export function sidecarContextOverflowResponse(
174
+ response: Response,
175
+ options: { stream: boolean; modelId: string; translatorBudget: TranslatorBudget },
176
+ ): Response {
177
+ if (response.ok || !sidecarOverflowResponses.has(response)) return response;
178
+ if (response.headers.get("x-should-retry") === "false") return response;
179
+ void response.body?.cancel().catch(() => {});
180
+ return options.stream
181
+ ? streamingContextOverflowResponse(options.modelId, options.translatorBudget)
182
+ : jsonContextOverflowResponse();
183
+ }
184
+
46
185
  /**
47
186
  * Convert a pre-stream provider 413 into the terminal Responses event Codex understands.
48
187
  *
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
2
  import type { OcxConfig, OcxProviderConfig, OcxParsedRequest } from "../../types";
2
3
  import type { CodexAuthContext, CodexAuthPolicyConfig } from "../../codex/auth-context";
3
4
  import type { CodexUpstreamOutcome } from "../../codex/routing";
@@ -144,7 +145,7 @@ export function recordCodexPreOutputRefusalOutcome(
144
145
  modelId: string,
145
146
  ): void {
146
147
  if (!isCodexWsPreOutputRefusal(response) || codexWsPreOutputRefusalOutcomeRecorded(response)) return;
147
- if (!usesCodexForwardPoolAuth(authCtx, provider)) return;
148
+ if (!usesCodexForwardPoolAuth(authCtx, provider) || wasPermanentAccountRefusal(response)) return;
148
149
  noteCodexWsPreOutputRefusalOutcome(response);
149
150
  recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
150
151
  threadId: authCtx.affinityKey,
@@ -855,6 +856,10 @@ export async function retryCodexPoolOnAlternateAccount(
855
856
  options.deferCodexResetDerivedCooldown,
856
857
  );
857
858
  const recordFirstOutcome = (): void => {
859
+ if (wasPermanentAccountRefusal(firstResponse)) {
860
+ noteCodexWsPreOutputRefusalOutcome(firstResponse);
861
+ return;
862
+ }
858
863
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
859
864
  ...quotaMeta,
860
865
  threadId: firstAuthCtx.affinityKey,
@@ -1032,6 +1037,7 @@ export async function retryCodexPoolOnAlternateAccount(
1032
1037
  providerName: route.providerName,
1033
1038
  modelId: route.modelId,
1034
1039
  preOutputRefusalHold: args.preOutputRefusalHold,
1040
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: retryAuthCtx }, headers),
1035
1041
  onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
1036
1042
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1037
1043
  ? createCodexAuthDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -45,6 +45,7 @@ import {
45
45
  } from "../../combos";
46
46
  import { formatErrorResponse } from "../../bridge";
47
47
  import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
48
+ import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
48
49
  import {
49
50
  expandPreviousResponseInput,
50
51
  previousResponseReplayFailure,
@@ -98,6 +99,7 @@ import type { CodexAuthContext } from "../../codex/auth-context";
98
99
  import type { ResponsesTerminalStatus } from "../../bridge";
99
100
  import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
100
101
  import { rememberComboForLane } from "./combo-session-recall";
102
+ import { COMBO_TOOL_LOOP_PIN_REASON, toolLoopPinnedPick } from "./combo-tool-loop-pin";
101
103
  import { runTurnAdapterSseResponses } from "./core-lifetime";
102
104
  import { normalizePersistedJevDecision } from "../../usage/jev-stats";
103
105
  import {
@@ -589,7 +591,19 @@ export async function executeComboResponses(
589
591
  waitForCooldownMs: combo.waitForCooldownMs,
590
592
  abortSignal: options.abortSignal,
591
593
  });
592
- let pick = await pickWithWait({
594
+ // An in-flight tool loop stays on the target that issued its calls while that target is still
595
+ // selectable (#7298). Override routes never record a lane target, so they never pin either.
596
+ const toolLoopPin = options.compactionRoutingOverride || options.mainHardLockFallbackOverride
597
+ ? undefined
598
+ : toolLoopPinnedPick({
599
+ config,
600
+ comboId,
601
+ lane: sessionLaneIdFromRequest(req.headers),
602
+ body,
603
+ eligible: target => (meteredAllowed || !target.metered) && targetEligible(target),
604
+ now: initialNow,
605
+ });
606
+ let pick = toolLoopPin ?? await pickWithWait({
593
607
  eligible: targetEligible,
594
608
  now: initialNow,
595
609
  });
@@ -625,7 +639,7 @@ export async function executeComboResponses(
625
639
  : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
626
640
  }
627
641
  let jevDecision: JevDecision | undefined;
628
- if (combo.strategy === "jev") {
642
+ if (combo.strategy === "jev" && !toolLoopPin) {
629
643
  const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
630
644
  const first = choices[0];
631
645
  if (!first) {
@@ -653,6 +667,7 @@ export async function executeComboResponses(
653
667
  isDestinationAllowed: (providerName, modelId) => routeAllowedByScope(
654
668
  resolveAdmissionModelScope(config, options.admission), { providerName, modelId },
655
669
  ),
670
+ ...(combo.decisionQuotaSignals === true ? { decisionQuotaSignals: true, decisionQuotaTiers: combo.decisionQuotaTiers } : {}),
656
671
  ...(combo.decisionProvider ? { decisionProvider: combo.decisionProvider } : {}),
657
672
  ...(combo.decisionModel
658
673
  ? {
@@ -696,6 +711,7 @@ export async function executeComboResponses(
696
711
  ? { chosenProbability: decision.chosenProbability }
697
712
  : {}),
698
713
  ...(decision.usage ? { usage: decision.usage } : {}),
714
+ ...(decision.quota ? { quota: decision.quota } : {}),
699
715
  });
700
716
  console.debug("[combo] JEV decision", {
701
717
  backend: decision.backend,
@@ -713,8 +729,11 @@ export async function executeComboResponses(
713
729
  // One immutable combo selection trace, before any child dispatch; child
714
730
  // adoption below must never replace it with a concrete child route trace.
715
731
  const decision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
716
- logCtx.routeDecision = options.memoryModelPhase
717
- ? { ...decision, selected: { ...decision.selected, reason: memoryModelRouteReason(options.memoryModelPhase) } }
732
+ const selectedReason = options.memoryModelPhase
733
+ ? memoryModelRouteReason(options.memoryModelPhase)
734
+ : toolLoopPin ? COMBO_TOOL_LOOP_PIN_REASON : undefined;
735
+ logCtx.routeDecision = selectedReason
736
+ ? { ...decision, selected: { ...decision.selected, reason: selectedReason } }
718
737
  : decision;
719
738
 
720
739
  const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
@@ -1193,8 +1212,15 @@ export async function executeComboResponses(
1193
1212
  }
1194
1213
  return lastFailure;
1195
1214
  }
1215
+ // A bare status made a revoked credential read as a provider outage. Log only the parsed
1216
+ // code and message, never the raw envelope, as one redacted, bounded line.
1217
+ const failureReason = sanitizeLogMetadataString(
1218
+ redactSecretString([failure.upstreamCode, failure.upstreamMessage].filter(Boolean).join(": ")).replace(/\s+/g, " "),
1219
+ 200,
1220
+ );
1196
1221
  console.warn(
1197
- `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
1222
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status}`
1223
+ + ` after ${Date.now() - started}ms${failureReason ? `: ${failureReason}` : ""}`,
1198
1224
  );
1199
1225
  const failureNow = Date.now();
1200
1226
  const attemptedTargets = pick.attempted;
@@ -12,6 +12,7 @@ import { createInferenceSendBudget } from "../inference/context";
12
12
  import { finalizeOwnedTranslatorBudget, finalizeAccountLease } from "./core-lifetime";
13
13
  import type { TranslatorBudget } from "../../lib/translator-budget";
14
14
  import { executeComboResponses } from "./core-combo";
15
+ import { registerTurn, unregisterTurn } from "../lifecycle";
15
16
  import { runWithCompactionRecovery } from "./compaction-recovery";
16
17
  /**
17
18
  * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
@@ -67,15 +68,17 @@ export async function handleComboResponses(
67
68
  logCtx: RequestLogContext,
68
69
  options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
69
70
  ): Promise<Response> {
70
- return executeComboResponses(
71
- req,
72
- rawBody,
73
- comboId,
74
- config,
75
- logCtx,
76
- options,
77
- requestDispatchers,
78
- );
71
+ // Each child relay binds the turn's lease and unbinds it when its stream ends; the lease is
72
+ // released once nothing is bound. Hold it for the whole ladder so a refused child's relay
73
+ // cannot settle the turn the next target still runs in ("turn already settled"). An accepted
74
+ // child's stream is bound before this returns and keeps the lease until it ends.
75
+ const ladder = new AbortController();
76
+ registerTurn(ladder, options.turnAdmissionLease);
77
+ try {
78
+ return await executeComboResponses(req, rawBody, comboId, config, logCtx, options, requestDispatchers);
79
+ } finally {
80
+ if (options.turnAdmissionLease && "bindAbortController" in options.turnAdmissionLease) unregisterTurn(ladder);
81
+ }
79
82
  }
80
83
  const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses };
81
84
  export { adapterNeedsForcedContinuation } from "./core-replay";
@@ -172,6 +172,7 @@ export function mergeUsage(
172
172
  const cachedInputTokens = sumOptional("cachedInputTokens");
173
173
  const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
174
174
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
175
+ const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
175
176
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
176
177
  const providerCredits = sumOptional("providerCredits");
177
178
  const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
@@ -188,6 +189,7 @@ export function mergeUsage(
188
189
  ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
189
190
  ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
190
191
  ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
192
+ ...(cacheCreation1hInputTokens !== undefined ? { cacheCreation1hInputTokens } : {}),
191
193
  ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
192
194
  ...(providerCredits !== undefined ? { providerCredits } : {}),
193
195
  ...(first.estimated || second.estimated ? { estimated: true } : {}),
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../lib/provider-egress";
32
32
  import { redactSecretString } from "../../lib/redact";
33
33
  import { outboundCredentials } from "../../lib/outbound-credential-registry";
34
+ import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression";
34
35
 
35
36
  export { withUpstreamHttpVersion };
36
37
 
@@ -48,6 +49,17 @@ const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided");
48
49
  const UPSTREAM_REWRITTEN = Symbol.for("opencodex.plugins.upstream-rewritten");
49
50
  // Survives queued credential rebuilds and executor replacement without importing budget owners.
50
51
  const PHYSICAL_DISPATCH = Symbol.for("opencodex.physical-dispatch");
52
+ // Final destination and headers after plugin rewrites and queued rebuilds, for observers that
53
+ // must attribute a response to the credential that actually reached the wire.
54
+ const PHYSICAL_SEND_OBSERVER = Symbol.for("opencodex.physical-send-observer");
55
+ export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
56
+ physical?: (sameDestination: boolean, headers: Headers) => void;
57
+ };
58
+ /** Init fragment that reports the final send of the observer's attempt; empty when nothing observes. */
59
+ export function physicalSendObserverInit(observer: AccountAutoPauseObserver | undefined, target: string): Record<symbol, unknown> {
60
+ const physical = observer?.physical;
61
+ return physical ? { [PHYSICAL_SEND_OBSERVER]: (destination: string, headers: Headers) => physical(destination === target, headers) } : {};
62
+ }
51
63
  // One WS attempt and its HTTP fallback are one logical physical-send receipt.
52
64
  const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt");
53
65
  // Replacement admission waits on the client signal, outside the response-header deadline.
@@ -206,18 +218,27 @@ export function sendWithConnectionPolicy(
206
218
  // Bun can reset large native Codex string uploads before receiving headers while
207
219
  // the identical UTF-8 buffer succeeds. Convert only at the final HTTP send so
208
220
  // WebSocket selection still sees the serialized string and no retry is added.
221
+ // Opt-in compression (#7247) takes precedence at the same boundary and also yields bytes. Every
222
+ // retry and HTTP fallback re-enters here with the caller's original string, so each send derives
223
+ // its own coded bytes; `headers` is already this send's private copy.
209
224
  const target = input instanceof Request ? input.url : String(input);
210
225
  const body = init?.body;
211
- const largeCodexBody = typeof body === "string"
212
- && /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target)
226
+ const compressed = compressUpstreamRequestBody(target, body, headers);
227
+ if (compressed) {
228
+ headers.set("Content-Encoding", compressed.encoding);
229
+ headers.delete("Content-Length");
230
+ }
231
+ const largeCodexBody = !compressed && typeof body === "string"
232
+ && isCanonicalCodexResponsesUrl(target)
213
233
  && Buffer.byteLength(body, "utf8") >= 1024 * 1024;
214
234
  (init as Record<symbol, (() => void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.();
235
+ (init as Record<symbol, ((destination: string, headers: Headers) => void) | undefined> | undefined)?.[PHYSICAL_SEND_OBSERVER]?.(target, headers);
215
236
  // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the
216
237
  // request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status).
217
238
  outboundCredentials().remember(headers);
218
239
  return physicalFetch(input, {
219
240
  ...init,
220
- ...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
241
+ ...(compressed ? { body: compressed.body } : largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
221
242
  headers,
222
243
  redirect: "manual",
223
244
  ...(fresh ? { keepalive: false } : {}),
@@ -228,6 +249,7 @@ export function sendWithConnectionPolicy(
228
249
  }
229
250
 
230
251
  export interface ProviderFetchOptions {
252
+ accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined;
231
253
  /**
232
254
  * Keep this send on HTTP even where the WebSocket upstream would normally be selected.
233
255
  *
@@ -344,7 +366,7 @@ export function providerFetch(
344
366
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
345
367
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
346
368
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
347
- const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
369
+ const unobserved = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
348
370
  reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
349
371
  const upstreamWebsocket = provider.upstreamWebsocket;
350
372
  if (!options.httpOnly && typeof input === "string" && init
@@ -381,6 +403,15 @@ export function providerFetch(
381
403
  }
382
404
  return httpFetch(input, init, reacquirePacing);
383
405
  };
406
+ const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
407
+ reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
408
+ const observe = options.accountAutoPause?.(providerName, provider,
409
+ new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
410
+ const observedInit = observe?.physical
411
+ ? { ...init, ...physicalSendObserverInit(observe, input instanceof Request ? input.url : String(input)) } : init;
412
+ const response = await unobserved(input, observedInit, reacquirePacing);
413
+ return observe ? observe(response) : response;
414
+ };
384
415
  let pacingSlotAcquired = options.pacingSlotAcquired === true;
385
416
  const waitForPacing = async (signal?: AbortSignal): Promise<ProviderRequestSlot | undefined> => {
386
417
  if (pacingSlotAcquired) {
@@ -1,6 +1,7 @@
1
1
  import { readBoundedResponseBytes } from "../../lib/bounded-body";
2
2
  import { JevModelInvokeError, type JevModelInvoke, type JevModelInvokeResult } from "../../combos/jev-model-backend";
3
3
  import { JEV_MAX_REQUEST_BYTES, JEV_MAX_RESPONSE_BYTES } from "../../combos/jev";
4
+ import { serializeJevModelRequest } from "../../combos/jev-model-request";
4
5
  import type { OcxConfig } from "../../types";
5
6
  import { codexEffortRank, isCodexReasoningEffort } from "../../reasoning-effort";
6
7
  import { routeConcreteModel } from "../../router";
@@ -92,7 +93,7 @@ function decodeSse(bytes: Uint8Array): JevModelInvokeResult {
92
93
  * bounds billed output (including reasoning) and gives the spend reservation a nonzero ceiling;
93
94
  * a truncated answer is malformed and fails open like any other bad reply.
94
95
  */
95
- export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024;
96
+ export { JEV_MODEL_MAX_OUTPUT_TOKENS } from "../../combos/jev-model-request";
96
97
 
97
98
  /**
98
99
  * The cheapest reasoning effort the decision model declares, so a reasoning model cannot spend
@@ -121,19 +122,17 @@ export function jevDecisionReasoningEffort(config: OcxConfig, model: string): st
121
122
  * or conversation header. It therefore runs on credentials configured on provider rows only.
122
123
  */
123
124
  export function createJevModelInvoker(context: JevModelInvokerContext): JevModelInvoke {
124
- return async ({ model, instructions, input, signal }) => {
125
+ return async ({ model, instructions, input, signal, withoutQuota }) => {
125
126
  const effort = jevDecisionReasoningEffort(context.config, model);
126
- const body = JSON.stringify({
127
- model,
128
- stream: true,
129
- store: false,
130
- instructions,
131
- input: [{ role: "user", content: [{ type: "input_text", text: input }] }],
132
- tools: [],
133
- max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS,
134
- ...(effort ? { reasoning: { effort } } : {}),
135
- });
136
- if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) {
127
+ let body = serializeJevModelRequest({ model, instructions, input }, effort);
128
+ /** True when the serialized decision request exceeds the request byte limit. */
129
+ const oversized = () => new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES;
130
+ let quotaOmitted = false;
131
+ if (oversized() && withoutQuota) {
132
+ body = serializeJevModelRequest({ model, ...withoutQuota }, effort);
133
+ quotaOmitted = true;
134
+ }
135
+ if (oversized()) {
137
136
  throw new JevModelInvokeError("malformed", "decision request too large");
138
137
  }
139
138
  const lease = tryAdmitTurn(undefined, signal);
@@ -178,7 +177,7 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel
178
177
  return terminalResult(parsed, "");
179
178
  })();
180
179
  usage = result.usage;
181
- return result;
180
+ return quotaOmitted ? { ...result, quotaOmitted: true } : result;
182
181
  } finally {
183
182
  // No final log row is written for this detached context, so settle its spend here.
184
183
  childLog.spendTracker?.settle(childLog.usage ?? (usage
@@ -24,7 +24,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
24
24
  if (usage && (usages.has(event.response.id) || usages.size < MAX_NATIVE_STEERING_RESPONSES)) {
25
25
  // Retain numeric counters only, never arbitrary rawUsage metadata.
26
26
  const counters: OcxUsage = { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
27
- for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
27
+ for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
28
28
  if (typeof usage[key] === "number") counters[key] = usage[key];
29
29
  }
30
30
  usages.set(event.response.id, counters);
@@ -33,7 +33,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
33
33
  if (!usages.size) return;
34
34
  const total: OcxUsage = { inputTokens: 0, outputTokens: 0 };
35
35
  for (const usage of usages.values()) {
36
- for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
36
+ for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
37
37
  const value = usage[key];
38
38
  if (typeof value === "number" && Number.isFinite(value)) total[key] = (total[key] ?? 0) + value;
39
39
  }
@@ -1,3 +1,4 @@
1
+ import { wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
2
  import { isNativeControlResponse } from "./native-response-control";
2
3
  import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction";
3
4
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
@@ -527,7 +528,7 @@ export async function deliverPassthroughResponse(
527
528
  } else if (!shouldDeferCodexResetDerivedCooldown(
528
529
  upstreamResponse,
529
530
  options.deferCodexResetDerivedCooldown,
530
- ) && !isReplayRefusalResponse(upstreamResponse)) {
531
+ ) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
531
532
  // A refusal this proxy made is not evidence about the account. Recording it would
532
533
  // classify the synthetic 429 as quota exhaustion and write a default cooldown against
533
534
  // a credential the request may never have reached, and that false signal outlives the
@@ -572,7 +573,7 @@ export async function deliverPassthroughResponse(
572
573
  // through sanitizePassthroughHeaders) so a redirect to a dead host can never
573
574
  // masquerade as a pre-connection failure after the credential was seen.
574
575
  // The numeric outcome above already classified it neutral — no streak.
575
- if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) {
576
+ if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
576
577
  return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal);
577
578
  }
578
579
  if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {