@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Opt-in JEV session pin (#7525).
3
+ *
4
+ * A JEV Combo decides a target per request, so consecutive turns of one conversation can land on
5
+ * different providers and each switch starts with a cold provider prompt cache. With
6
+ * `sessionPin` set, a session keeps the target JEV chose for it while the session stays active.
7
+ *
8
+ * Identity. A pin is keyed by the admitted caller (the salted context principal of the matched
9
+ * data-plane key, or `loopback`), the Combo id and the explicit session lane from
10
+ * `sessionLaneIdFromRequest` (thread, session and parent-thread headers). A request without an
11
+ * explicit session identity never pins, and a body field such as `prompt_cache_key` is never an
12
+ * identity on its own.
13
+ *
14
+ * Lifetime. A pin is written only after the target JEV applied for this session completes a
15
+ * response, and it is refreshed by every later completion on that same target, including tool-loop
16
+ * continuations. It is released when the session stays idle past `ttlMs`, when any config
17
+ * generation change happens after it was written, when its target is not among the request's
18
+ * current JEV choices (disabled, removed, cooling down, quota exhausted, metered or otherwise
19
+ * ineligible), when its effort is no longer offered, when opted-in quota evidence reads
20
+ * `nearly_exhausted`, and when any target of a pinned session fails; a request that was already in
21
+ * flight when that failure was recorded cannot write the pin back. A cancelled send writes and
22
+ * releases nothing. Storage is process-local and bounded by `JEV_SESSION_PIN_CAPACITY`.
23
+ */
24
+ import type { JevCandidate, JevDecision, JevDecisionBackend } from "../../combos/jev";
25
+ import { jevQuotaSignalForTarget } from "../../combos/jev-quota";
26
+ import { JEV_QUOTA_DEFAULT_TIERS, normalizeJevQuotaTiers, type JevQuotaTiers } from "../../combos/jev-quota-config";
27
+ import type { JevSessionPinRelease } from "../../combos/jev-session-pin-config";
28
+ import type { NormalizedComboConfig } from "../../combos/types";
29
+ import { captureConfigGeneration } from "../../lib/state-store-sweeper";
30
+ import type { OcxComboDefaultEffort, OcxConfig } from "../../types";
31
+ import type { DataPlaneAdmission } from "../auth-cors";
32
+
33
+ export const JEV_SESSION_PIN_ROUTE_REASON = "jev-session-pin";
34
+ export const JEV_SESSION_PIN_CAPACITY = 512;
35
+
36
+ interface PinEntry {
37
+ targetKey: string;
38
+ effort: OcxComboDefaultEffort | null;
39
+ backend: JevDecisionBackend;
40
+ at: number;
41
+ generation: number;
42
+ }
43
+
44
+ const pins = new Map<string, PinEntry>();
45
+ /**
46
+ * Failure fence per session key. `forget()` stamps the key with a fresh sequence number; a request
47
+ * that started before that stamp may no longer write the pin back, so a concurrent completion on
48
+ * the same session cannot restore a target another request just saw fail. Bounded like the pins;
49
+ * because an evicted stamp is unknowable, a writer whose key has no stamp is also refused once any
50
+ * stamp was evicted after it started.
51
+ */
52
+ const failureFences = new Map<string, number>();
53
+ let failureSequence = 0;
54
+ /** `failureSequence` at the most recent fence eviction. */
55
+ let fenceEvictedAt = 0;
56
+
57
+ function callerNamespace(admission: DataPlaneAdmission | undefined): string {
58
+ if (!admission) return "unadmitted";
59
+ if (admission.kind === "loopback") return "loopback";
60
+ if (admission.contextPrincipalId) return `principal:${admission.contextPrincipalId}`;
61
+ return admission.kind === "configured" ? `key:${admission.keyId}` : admission.kind;
62
+ }
63
+
64
+ function write(key: string, entry: PinEntry): void {
65
+ // Insertion order is recency order; evicting from the front drops the least recent session.
66
+ pins.delete(key);
67
+ pins.set(key, entry);
68
+ while (pins.size > JEV_SESSION_PIN_CAPACITY) {
69
+ const oldest = pins.keys().next().value;
70
+ if (oldest === undefined) break;
71
+ pins.delete(oldest);
72
+ }
73
+ }
74
+
75
+ function fenceFailure(key: string): void {
76
+ failureFences.delete(key);
77
+ failureFences.set(key, ++failureSequence);
78
+ while (failureFences.size > JEV_SESSION_PIN_CAPACITY) {
79
+ const oldest = failureFences.keys().next().value;
80
+ if (oldest === undefined) break;
81
+ failureFences.delete(oldest);
82
+ fenceEvictedAt = failureSequence;
83
+ }
84
+ }
85
+
86
+ export interface JevSessionPinReuse {
87
+ /** The pinned decision to apply instead of asking the decision backend. */
88
+ decision?: JevDecision;
89
+ /** Set when a pin existed and was released instead of reused. */
90
+ released?: JevSessionPinRelease;
91
+ }
92
+
93
+ export interface JevSessionPinScope {
94
+ /**
95
+ * Reuse the session's pin against this request's current JEV choices, or report its release.
96
+ * `quota` is passed only when the Combo opted into advisory quota evidence; it is read from
97
+ * loaded snapshots exactly as the decision payload would read it.
98
+ */
99
+ reuse(
100
+ choices: readonly JevCandidate[],
101
+ quota?: { config: OcxConfig; tiers?: JevQuotaTiers },
102
+ ): JevSessionPinReuse;
103
+ /**
104
+ * Record a completed response. With the request's applied JEV decision it (re)writes the pin
105
+ * when the completed target is the decided one; without one it only refreshes a pin that
106
+ * already names the completed target.
107
+ */
108
+ complete(completedTargetKey: string, decision: JevDecision | undefined, writerGeneration: number): void;
109
+ /** Drop the session's pin after a real target failure. */
110
+ forget(): void;
111
+ }
112
+
113
+ /** The pin scope for one Combo request, or undefined when this request can never pin. */
114
+ export function jevSessionPinScope(args: {
115
+ combo: Pick<NormalizedComboConfig, "strategy" | "sessionPin">;
116
+ comboId: string;
117
+ admission: DataPlaneAdmission | undefined;
118
+ lane: string | undefined;
119
+ now?: () => number;
120
+ }): JevSessionPinScope | undefined {
121
+ const ttlMs = args.combo.sessionPin?.ttlMs;
122
+ if (args.combo.strategy !== "jev" || ttlMs === undefined || !args.lane) return undefined;
123
+ const key = `${callerNamespace(args.admission)}\u0000${args.comboId}\u0000${args.lane}`;
124
+ const now = args.now ?? Date.now;
125
+ const fence = failureFences.get(key) ?? 0;
126
+ const startedAtSequence = failureSequence;
127
+ const release = (reason: JevSessionPinRelease): JevSessionPinReuse => {
128
+ pins.delete(key);
129
+ return { released: reason };
130
+ };
131
+ return {
132
+ reuse(choices, quota) {
133
+ const entry = pins.get(key);
134
+ if (!entry) return {};
135
+ if (entry.generation !== captureConfigGeneration()) return release("config_changed");
136
+ const at = now();
137
+ if (at < entry.at || at - entry.at >= ttlMs) return release("expired");
138
+ const candidate = choices.find(choice => choice.key === entry.targetKey);
139
+ if (!candidate) return release("target_unavailable");
140
+ if (entry.effort !== null && !candidate.reasoningEfforts.includes(entry.effort)) {
141
+ return release("effort_unavailable");
142
+ }
143
+ if (quota && candidate.quotaIdentity !== null) {
144
+ const identity = candidate.quotaIdentity ?? candidate;
145
+ const tiers = normalizeJevQuotaTiers(quota.tiers) ?? JEV_QUOTA_DEFAULT_TIERS;
146
+ const signal = jevQuotaSignalForTarget(quota.config, identity.provider, identity.model, at, tiers);
147
+ if (signal.tier === "nearly_exhausted") return release("quota_nearly_exhausted");
148
+ }
149
+ return {
150
+ decision: {
151
+ backend: entry.backend,
152
+ targetKey: entry.targetKey,
153
+ effort: entry.effort,
154
+ gate: "apply",
155
+ latencyMs: 0,
156
+ pinned: true,
157
+ pinReason: "session",
158
+ },
159
+ };
160
+ },
161
+ complete(completedTargetKey, decision, writerGeneration) {
162
+ // A config change while the response was in flight makes this completion obsolete.
163
+ if (writerGeneration !== captureConfigGeneration()) return;
164
+ // So does a failure on this session reported after this request started.
165
+ const currentFence = failureFences.get(key);
166
+ if ((currentFence ?? 0) !== fence) return;
167
+ if (currentFence === undefined && fenceEvictedAt > startedAtSequence) return;
168
+ if (!decision) {
169
+ const entry = pins.get(key);
170
+ if (entry && entry.targetKey === completedTargetKey && entry.generation === writerGeneration) {
171
+ write(key, { ...entry, at: now() });
172
+ }
173
+ return;
174
+ }
175
+ // Only an applied decision pins; a fail-open fallback leaves the next turn to decide again.
176
+ if (decision.gate !== "apply" || decision.targetKey !== completedTargetKey) return;
177
+ write(key, {
178
+ targetKey: decision.targetKey,
179
+ effort: decision.effort,
180
+ backend: decision.backend,
181
+ at: now(),
182
+ generation: writerGeneration,
183
+ });
184
+ },
185
+ forget() {
186
+ pins.delete(key);
187
+ fenceFailure(key);
188
+ },
189
+ };
190
+ }
191
+
192
+ /** Test-only reset, alongside the Combo recall and rotation resets. */
193
+ export function clearJevSessionPinsForTests(): void {
194
+ pins.clear();
195
+ failureFences.clear();
196
+ failureSequence = 0;
197
+ fenceEvictedAt = 0;
198
+ }
199
+
200
+ /** Test-only view of the retained pin count. */
201
+ export function jevSessionPinCountForTests(): number {
202
+ return pins.size;
203
+ }
@@ -1,8 +1,8 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { resolveComboId } from "../../combos";
3
- import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router";
3
+ import { isBareOpenAiFamilyModel, previewRouteModel, routeConcreteModel } from "../../router";
4
4
  import { resolveChosenTarget } from "./shadow-target-availability";
5
- import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile";
5
+ import { getRoutingProfile, resolvePolicyProfileId, type NormalizedRoutingProfile } from "../../routing/profile";
6
6
  import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
7
7
  import {
8
8
  getMainAccountHardLockStatus,
@@ -12,6 +12,7 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
12
12
  import { getEligiblePoolAccounts } from "../../codex/routing/selection";
13
13
  import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
14
14
  import { providerCodexAccountMode } from "../../providers/registry";
15
+ import { FallbackRefusedError, guardFallbackHop, type FallbackAdmission, type FallbackTarget } from "../../routing/fallback-policy";
15
16
  /** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
16
17
  export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
17
18
 
@@ -81,6 +82,7 @@ export async function applyMainHardLockFallbackOverride(
81
82
  config: OcxConfig,
82
83
  now?: number,
83
84
  callerCheck?: MainHardLockCallerCheck,
85
+ admission?: FallbackAdmission,
84
86
  ): Promise<MainHardLockFallbackOverride | null> {
85
87
  const parsed = rawModelOf(body);
86
88
  if (!parsed) return null;
@@ -120,6 +122,21 @@ export async function applyMainHardLockFallbackOverride(
120
122
  }
121
123
  }
122
124
 
125
+ // OCX-11: the reroute is a proactive model hop; refused, the request is answered with the
126
+ // fallback refusal and the body is left untouched.
127
+ const hop = guardFallbackHop(config, {
128
+ site: "hard-lock",
129
+ route: parsed.model,
130
+ from: { provider: OPENAI_CODEX_PROVIDER_ID, model: parsed.model },
131
+ to: hardLockTargetOf(config, parsed.model, fallback.model),
132
+ trigger: "hard_lock",
133
+ admission,
134
+ ...(now !== undefined ? { now } : {}),
135
+ });
136
+ if (!hop.ok) throw new FallbackRefusedError(hop.response);
137
+ // The rewrite is this site's admission: the body now names the fallback model.
138
+ hop.commit();
139
+
123
140
  parsed.raw.model = fallback.model;
124
141
  if (fallback.reasoningEffort !== undefined) {
125
142
  const reasoning = parsed.raw.reasoning;
@@ -132,3 +149,38 @@ export async function applyMainHardLockFallbackOverride(
132
149
  }
133
150
  return { sourceModel: parsed.model, targetModel: fallback.model };
134
151
  }
152
+
153
+ /**
154
+ * The hop's destination for the fallback policy, read from config only. A routing profile picks
155
+ * its candidate from request-history evidence, and evaluating it here would open that index under
156
+ * OPENCODEX_HOME just to classify the hop (on Windows the open handle then outlives the request's
157
+ * home); the profile's own dispatch still evaluates it. Its declared candidates are classified
158
+ * instead, as the hop the profile could take: a candidate on another provider makes it a provider
159
+ * hop. Combos, account namespaces and concrete targets resolve from config alone.
160
+ */
161
+ function hardLockTargetOf(config: OcxConfig, sourceModel: string, model: string): FallbackTarget {
162
+ const policyId = resolvePolicyProfileId(config, model);
163
+ const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
164
+ if (profile) return profileHopTarget(config, sourceModel, profile);
165
+ try {
166
+ const route = previewRouteModel(config, model);
167
+ return { provider: route.providerName, model: route.modelId };
168
+ } catch {
169
+ const slash = model.indexOf("/");
170
+ return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
171
+ }
172
+ }
173
+
174
+ function profileHopTarget(config: OcxConfig, sourceModel: string, profile: NormalizedRoutingProfile): FallbackTarget {
175
+ const targets = profile.candidates.map((candidate): FallbackTarget => {
176
+ try {
177
+ const route = routeConcreteModel(config, candidate.provider + "/" + candidate.model);
178
+ return { provider: route.providerName, model: route.modelId };
179
+ } catch {
180
+ return { provider: candidate.provider, model: candidate.model };
181
+ }
182
+ });
183
+ return targets.find(target => target.provider !== OPENAI_CODEX_PROVIDER_ID)
184
+ ?? targets.find(target => target.model !== sourceModel)
185
+ ?? { provider: OPENAI_CODEX_PROVIDER_ID, model: sourceModel };
186
+ }
@@ -80,6 +80,7 @@ import {
80
80
  createResponsesItemIdPayloadRewrite,
81
81
  repairResponsesJsonItemIds,
82
82
  } from "../responses-item-id-repair";
83
+ import { createResponsesPhaseInferenceBlockRewrite } from "../responses-phase-inference";
83
84
  import {
84
85
  payloadRewriteAsBlockRewrite,
85
86
  composeSsePayloadRewrites,
@@ -851,6 +852,9 @@ export async function deliverPassthroughResponse(
851
852
  snapshotRepairEnabled
852
853
  ? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget)
853
854
  : undefined,
855
+ route.provider.messagePhaseInference === true
856
+ ? createResponsesPhaseInferenceBlockRewrite(translatorBudget)
857
+ : undefined,
854
858
  responseEffects.plaintextV2AgentMessageToolNames.size > 0
855
859
  ? payloadRewriteAsBlockRewrite(createPlaintextV2AgentMessageCallRestoreRewrite(
856
860
  responseEffects.plaintextV2AgentMessageToolNames, responseEffects.plaintextV2AgentMessageAliasedToolNames,
@@ -13,6 +13,7 @@ import { resolvePolicyProfileId } from "../../routing/profile";
13
13
  import { parseSyntheticRowId } from "../fast-row";
14
14
  import { routeConcreteModel } from "../../router";
15
15
  import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope";
16
+ import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
16
17
 
17
18
  type CoreHandler = typeof handleResponsesCore;
18
19
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -25,6 +26,23 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
25
26
  return `${candidate.provider}\u0000${candidate.model}`;
26
27
  }
27
28
 
29
+ /**
30
+ * OCX-11: the destination a candidate physically dispatches to, after provider aliases and
31
+ * blockedModelRedirects, so the fallback guard compares where requests really go. A candidate
32
+ * that does not resolve keeps its own selector.
33
+ */
34
+ function resolvedPolicyTarget(
35
+ config: OcxConfig,
36
+ candidate: Pick<RouteCandidateTrace, "provider" | "model">,
37
+ ): { provider: string; model: string } {
38
+ try {
39
+ const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`);
40
+ return { provider: routed.providerName, model: routed.modelId };
41
+ } catch {
42
+ return { provider: candidate.provider, model: candidate.model };
43
+ }
44
+ }
45
+
28
46
  function staysWithinPolicyAfterRedirect(
29
47
  config: OcxConfig,
30
48
  policyEligibility: ReadonlySet<string>,
@@ -219,12 +237,32 @@ export async function handleResponsesWithPolicyFallback(
219
237
  policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({
220
238
  providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model,
221
239
  }));
240
+ let current = resolvedPolicyTarget(config, initialTrace.selected);
241
+ const policyRoute = `policy/${initialTrace.profile!.id}`;
222
242
 
223
243
  while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
224
244
  if (req.signal.aborted) return response;
225
245
  const next = rankPolicyFallbackCandidates(initialTrace, tried)
226
246
  .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations));
227
247
  if (!next) return response;
248
+ // OCX-11: the next candidate is a hop; the fallback policy decides before it is sent, on the
249
+ // redirected destination the guard above already validated.
250
+ const nextTarget = resolvedPolicyTarget(config, next);
251
+ const hop = guardFallbackHop(config, {
252
+ site: "routing-profile",
253
+ route: policyRoute,
254
+ aliases: [initialRequestedModel ?? initialTrace.requestedModel],
255
+ from: current,
256
+ to: nextTarget,
257
+ trigger: fallbackTriggerForStatus(response.status),
258
+ admission: options.admission,
259
+ failureStatus: response.status,
260
+ retryAfter: response.headers.get("retry-after"),
261
+ });
262
+ if (!hop.ok) {
263
+ try { await response.body?.cancel(); } catch { /* the refusal replaces this body */ }
264
+ return hop.response;
265
+ }
228
266
  tried.add(candidateKey(next));
229
267
 
230
268
  // Retain the failed response's log owner until a candidate actually replaces it. This is
@@ -243,7 +281,12 @@ export async function handleResponsesWithPolicyFallback(
243
281
  if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key);
244
282
  }
245
283
  Object.assign(logCtx, failureLog);
246
- } else response = nextResponse;
284
+ } else {
285
+ // Dispatched: only a candidate that was not locally skipped counts as a taken hop.
286
+ hop.commit();
287
+ response = nextResponse;
288
+ current = nextTarget;
289
+ }
247
290
  if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination);
248
291
  } catch (error) {
249
292
  const overload = requestPacingOverloadResponse(error);
@@ -30,6 +30,9 @@ import { recallComboForLane } from "./combo-session-recall";
30
30
  import {
31
31
  sessionLaneIdFromRequest,
32
32
  conversationIdFromResponsesRequest,
33
+ assignNativeLogConversationId,
34
+ fillHermesLogConversationFallback,
35
+ nativeLogConversationIdFromHeaders,
33
36
  sessionIdHeaderFromRequest,
34
37
  reasoningReplayConversationIdFromResponsesRequest,
35
38
  } from "../request-log-conversation";
@@ -160,6 +163,7 @@ import {
160
163
  import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
161
164
  import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
162
165
  import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing";
166
+ import { FallbackRefusedError } from "../../routing/fallback-policy";
163
167
  import { codexAuthContextLogLabel } from "../../codex/account-label";
164
168
  import {
165
169
  conversationStateBindingFromAuth,
@@ -367,10 +371,11 @@ export async function prepareResponsesRequest(
367
371
  && !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
368
372
  try {
369
373
  options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(
370
- body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options),
374
+ body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), options.admission,
371
375
  );
372
376
  } catch (error) {
373
377
  if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
378
+ if (error instanceof FallbackRefusedError) return error.response;
374
379
  const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() });
375
380
  if (mapped) return mapped;
376
381
  throw error;
@@ -601,14 +606,16 @@ export async function prepareResponsesRequest(
601
606
  // would become enough to read another caller's retained search result. Without a principal
602
607
  // bridgeSearchReplayScope yields no scope, so nothing is recorded or restored for it. The
603
608
  // field is always rewritten so an absent principal also clears one a reused holder carried.
604
- const clientPrincipalId = resolveContextPrincipal(req, config, options.admission);
609
+ const clientPrincipalId = options.replayPrincipal
610
+ ? options.replayPrincipal.clientPrincipalId
611
+ : resolveContextPrincipal(req, config, options.admission);
605
612
  parsed._reasoningReplayScope = { ...parsed._reasoningReplayScope, clientPrincipalId };
606
613
  }
607
614
  // Prefer a pre-populated id (routed Claude) over Responses headers that may be
608
- // absent or synthetically injected (session_id from prompt_cache_key).
609
- if (!logCtx.conversationId) {
610
- logCtx.conversationId = resolvedConversationId;
611
- }
615
+ // absent or synthetically injected (session_id from prompt_cache_key). A Hermes-only id
616
+ // is a log fallback: native resolution replaces it, and it only fills an empty slot.
617
+ assignNativeLogConversationId(logCtx, resolvedConversationId ?? nativeLogConversationIdFromHeaders(req.headers));
618
+ fillHermesLogConversationFallback(logCtx, req.headers);
612
619
  // A rewritten turn logs the selector the client asked for, so the usage log never
613
620
  // claims the operator typed the fallback model themselves.
614
621
  logCtx.requestedModel = options.mainHardLockFallbackOverride?.sourceModel
@@ -1005,7 +1012,9 @@ export async function prepareResponsesRequest(
1005
1012
  subagentFallbackModelEligibleAccountIdsForModel,
1006
1013
  fallbackChain,
1007
1014
  candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire),
1015
+ options.admission,
1008
1016
  );
1017
+ if (fallback?.refusal) return fallback.refusal;
1009
1018
  if (fallback) {
1010
1019
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1011
1020
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -1219,11 +1228,15 @@ export async function prepareResponsesRequest(
1219
1228
  recoverySelectionOptions,
1220
1229
  subagentFallbackAccountPreview,
1221
1230
  subagentFallbackModelEligibleAccountIdsForModel,
1231
+ undefined,
1232
+ undefined,
1233
+ options.admission,
1222
1234
  );
1223
1235
  } finally {
1224
1236
  recoverySelectionAdmission?.release();
1225
1237
  }
1226
1238
  })();
1239
+ if (fallback?.refusal) return fallback.refusal;
1227
1240
  if (fallback) {
1228
1241
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1229
1242
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -41,12 +41,14 @@ import {
41
41
  import {
42
42
  GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST,
43
43
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
44
+ antigravityModelRefusal,
44
45
  eligibleFailoverAccounts,
45
46
  forgetGenericFailoverRoster,
46
47
  isGenericFailoverProvider,
47
48
  isProactivePreferenceEnabled,
48
49
  preferredInitialAccount,
49
50
  refusalAwareInitialKiroAccount,
51
+ routeAntigravityModel,
50
52
  noteGenericPoolSelection,
51
53
  } from "../../oauth/generic-account-failover";
52
54
  import { admitGenericSessionAccount, bindGenericSessionAccount, genericSessionAccount, genericSessionAccountEligible, genericSessionAffinityEnabled, preferredGenericSessionAccount } from "../../oauth/generic-session-affinity";
@@ -79,7 +81,7 @@ import { recordAnthropicAccountQuotaFromHeadersForInstance, hasPassiveAccountQuo
79
81
  import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
80
82
  import { formatErrorResponse } from "../../bridge";
81
83
  import { bindRouteReasoningReplayScope } from "./core-replay";
82
- import { sessionIdHeaderFromRequest, normalizeLogConversationId } from "../request-log-conversation";
84
+ import { assignNativeLogConversationId, sessionIdHeaderFromRequest, normalizeLogConversationId } from "../request-log-conversation";
83
85
  import { redactSecretString } from "../../lib/redact";
84
86
  import { captureAccountAutoPauseAttempt } from "../../oauth/account-auto-pause";
85
87
  import { selectProactiveApiKeyTransport } from "../../providers/key-failover";
@@ -183,6 +185,7 @@ export async function prepareResponsesTransport(
183
185
  || route.providerName === "orcarouter-oauth"
184
186
  // runTurn transport: the replay runs on the first-event preflight in run-turn-execution.
185
187
  || route.providerName === "devin"
188
+ || route.providerName === "workbuddy-ai"
186
189
  || route.providerName === "dsh-account"
187
190
  ) && route.provider.authMode === "oauth";
188
191
  let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
@@ -244,6 +247,9 @@ export async function prepareResponsesTransport(
244
247
  if (candidate.provider !== route.providerName) return null;
245
248
  if (anthropicInstance) anthropicRouteDecision = currentAnthropicRouteDecision();
246
249
  if (anthropicRouteDecision && !routeCandidates(anthropicRouting!.getEligibleAnthropicAccounts(Date.now(), route.modelId), anthropicRouteDecision).includes(candidate.accountId)) return null;
250
+ // #7500: no admission, initial or a recovery/failover rebind, may land on an Antigravity
251
+ // account whose own roster omits the model. Callers abandon the send on null.
252
+ if (antigravityModelRefusal(config, route.providerName, candidate.accountId, route.modelId)) return null;
247
253
  const committed = genericAffinityMode
248
254
  ? admitGenericSessionAccount(config, route.providerName, oauthSelection, candidate, route.modelId, genericAffinityMode)
249
255
  : await commitOAuthAccountSelection(route.providerName, candidate.accountId, {
@@ -485,6 +491,10 @@ export async function prepareResponsesTransport(
485
491
  parsed._kiroAuthContext = kiroContext;
486
492
  if (retryParsed !== parsed) retryParsed._kiroAuthContext = { ...kiroContext };
487
493
  }
494
+ if (route.providerName === "workbuddy-ai") {
495
+ parsed._workbuddyAuthContext = snapshot.workbuddy;
496
+ if (retryParsed !== parsed) retryParsed._workbuddyAuthContext = snapshot.workbuddy;
497
+ }
488
498
  if (route.providerName === "zed") {
489
499
  // Zed signs with its own user id; `snapshot.accountId` is the local slot hash.
490
500
  const zedContext = { userId: snapshot.providerUserId ?? "" };
@@ -978,10 +988,16 @@ export async function prepareResponsesTransport(
978
988
  ?? refusalAwareId ?? (isGenericFailoverProvider(route.providerName, route.provider)
979
989
  ? preferredInitialAccount(config, route.providerName, Date.now(), route.modelId)
980
990
  : null);
991
+ // #7500: an Antigravity account whose own discovery omits the model is never sent to.
992
+ // A usable listing (or not-yet-discovered) sibling replaces it, or the request is refused.
993
+ const modelRoute = routeAntigravityModel(config, route.providerName,
994
+ proposedAccountId ?? oauthSelection?.accountId, route.modelId);
995
+ if (modelRoute.kind === "refuse") return formatErrorResponse(403, "permission_error", modelRoute.message);
996
+ const routedAccountId = modelRoute.kind === "reroute" ? modelRoute.accountId : proposedAccountId;
981
997
  // An unchanged active choice must retain the ordinary resolver's terminal-refresh
982
998
  // recovery (not the speculative-preference fallback that resolves the same dead grant).
983
- const preferredAccountId = genericAffinityMode && proposedAccountId === oauthSelection?.accountId
984
- ? null : proposedAccountId;
999
+ const preferredAccountId = genericAffinityMode && routedAccountId === oauthSelection?.accountId
1000
+ ? null : routedAccountId;
985
1001
  let safetyAlternateId: string | null = !affinityAccountId && preferredAccountId === refusalAwareId ? refusalAwareId : null;
986
1002
  // Resolved account-scoped, NOT through failoverAccountSnapshot: that helper marks a
987
1003
  // rotation site, and rotation sites must apply their credential through
@@ -1041,6 +1057,10 @@ export async function prepareResponsesTransport(
1041
1057
  resolved = await getValidAccessTokenSnapshot(route.providerName);
1042
1058
  usedPreferredAccount = false;
1043
1059
  }
1060
+ // Resolution fallbacks can still land on an omitting account; answer with the refusal
1061
+ // here, since the commit below would only report it as a changed selection.
1062
+ const unlisted = antigravityModelRefusal(config, route.providerName, resolved.accountId, route.modelId);
1063
+ if (unlisted) return formatErrorResponse(403, "permission_error", unlisted);
1044
1064
  const admitted = await commitResolvedOAuthSelection(resolved, safetyAlternateId === null);
1045
1065
  if (!admitted) return formatErrorResponse(409, "conflict_error", "OAuth account selection changed; retry the request");
1046
1066
  if (safetyAlternateId && admitted.accountId !== safetyAlternateId)
@@ -1104,6 +1124,7 @@ export async function prepareResponsesTransport(
1104
1124
  // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
1105
1125
  parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
1106
1126
  }
1127
+ if (route.providerName === "workbuddy-ai") parsed._workbuddyAuthContext = resolved.workbuddy;
1107
1128
  if (route.providerName === "zed") {
1108
1129
  parsed._zedAuthContext = { userId: resolved.providerUserId ?? "" };
1109
1130
  }
@@ -1198,9 +1219,7 @@ export async function prepareResponsesTransport(
1198
1219
  codexAuthContext: admissionState.authCtx,
1199
1220
  forwardHeaders: requestState.selectedForwardHeaders,
1200
1221
  });
1201
- if (!logCtx.conversationId && parsed._cursorConversationId) {
1202
- logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
1203
- }
1222
+ assignNativeLogConversationId(logCtx, normalizeLogConversationId(parsed._cursorConversationId));
1204
1223
  logCtx.providerAdapter = adapter.name;
1205
1224
  // Ordinary requests receive one durable attempt only after their final initial
1206
1225
  // adapter is resolved. Combo children own their attempt and retries keep it.
@@ -19,7 +19,7 @@ import { releaseProviderRequestSlot, waitForProviderRequestSlot, RequestPacingQu
19
19
  import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
20
20
  import type { AttemptRecoveryKind } from "../../usage/log";
21
21
  import { providerFetch } from "./fetch-helpers";
22
- import { normalizeLogConversationId } from "../request-log-conversation";
22
+ import { assignNativeLogConversationId, normalizeLogConversationId } from "../request-log-conversation";
23
23
  import { normalizeDeclaredToolName, type AdapterEvent, type OcxProviderContinuationState } from "../../types";
24
24
  import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE, SPEND_LEDGER_STORAGE_UNAVAILABLE_CODE, type OcxErrorPayload } from "../../lib/errors";
25
25
  import { SendBudgetExhaustedError, markResponseNonReplayable } from "../../lib/upstream-retry";
@@ -406,9 +406,7 @@ export async function executeResponsesRunTurn(
406
406
  releaseProviderRequestSlot(pacingSlot);
407
407
  // Cursor assigns a stable conversation id inside runTurn on the first headerless
408
408
  // turn; backfill so Logs can filter/total that opening request (#330 / #522).
409
- if (!logCtx.conversationId && parsed._cursorConversationId) {
410
- logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
411
- }
409
+ assignNativeLogConversationId(logCtx, normalizeLogConversationId(parsed._cursorConversationId));
412
410
  targetQueue.close();
413
411
  }
414
412
  };
@@ -51,7 +51,7 @@ import { namespacedToolName } from "../../types";
51
51
  import { providerFetch } from "./fetch-helpers";
52
52
  import type { AttemptRecoveryKind } from "../../usage/log";
53
53
  import { recordAdapterReasoning, recordAdapterTier } from "../request-log";
54
- import { normalizeLogConversationId } from "../request-log-conversation";
54
+ import { assignNativeLogConversationId, normalizeLogConversationId } from "../request-log-conversation";
55
55
  import { attemptAccountChanged } from "../request-log-account-rotation";
56
56
  import { rememberResponseState } from "../../responses/state";
57
57
  import { trackStreamLifetime } from "../lifecycle";
@@ -592,9 +592,7 @@ export async function executeResponsesSidecars(
592
592
  // Cursor may assign _cursorConversationId inside the image loop's first runTurn;
593
593
  // backfill so Logs can filter/total that opening request (parity with the normal
594
594
  // runTurn branch).
595
- if (!logCtx.conversationId && parsed._cursorConversationId) {
596
- logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
597
- }
595
+ assignNativeLogConversationId(logCtx, normalizeLogConversationId(parsed._cursorConversationId));
598
596
  transportState.bindKeyUsageFromBridge(usage);
599
597
  },
600
598
  on429: rotateSidecarProviderOn429,