@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -147,7 +147,6 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
147
147
  { method: "GET", path: "/api/injection-model", module: "server/management/agent-settings-routes", mutates: false },
148
148
  { method: "POST", path: "/api/injection-model/suggest", module: "server/management/agent-settings-routes", mutates: false },
149
149
  { method: "GET", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: false },
150
- { method: "GET", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: false },
151
150
  { method: "GET", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: false },
152
151
  { method: "POST", path: "/api/claude-desktop/apply", module: "server/management/agent-settings-routes", mutates: true },
153
152
  { method: "POST", path: "/api/grok/apply", module: "server/management/agent-settings-routes", mutates: true },
@@ -169,8 +168,10 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
169
168
  { method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true },
170
169
  { method: "PUT", path: "/api/injection-model", module: "server/management/agent-settings-routes", mutates: true },
171
170
  { method: "PUT", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: true },
172
- { method: "PUT", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: true },
173
171
  { method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true },
172
+ // server/management/subagent-model-routes
173
+ { method: "GET", path: "/api/subagent-models", module: "server/management/subagent-model-routes", mutates: false },
174
+ { method: "PUT", path: "/api/subagent-models", module: "server/management/subagent-model-routes", mutates: true },
174
175
  // server/management/codex-agent-role-routes
175
176
  { method: "GET", path: "/api/codex-agent-roles", module: "server/management/codex-agent-role-routes", mutates: false },
176
177
  { method: "POST", path: "/api/codex-agent-roles/auto-assign", module: "server/management/codex-agent-role-routes", mutates: false, mechanism: "path-constant" },
@@ -388,7 +389,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
388
389
  { method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard surface: a paired session, or on a standalone runtime the current loopback dashboard session on trusted loopback ingress; admin-token and Tailscale identity sessions are refused." } },
389
390
  { method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive dashboard consent flow (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
390
391
  { method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the dashboard session that saw the fingerprint (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
391
- { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It is the dashboard's turn-on flow with a pre-join notice (paired, or standalone loopback dashboard on trusted loopback ingress); admin-token and Tailscale identity sessions are refused." } },
392
+ { method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It requires an operator-paired dashboard session and a pre-join notice; unpaired standalone loopback, admin-token and Tailscale identity sessions are refused." } },
392
393
  { method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires a dashboard session (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
393
394
  { method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
394
395
  { method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
@@ -100,7 +100,7 @@ export type CostResult =
100
100
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
101
101
  | { kind: "unavailable"; reason: MetricUnavailableReason };
102
102
 
103
- export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "firstOutputMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision" | "cacheProvenance"> & {
103
+ export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "firstOutputMs" | "genStartMs" | "lastOutputMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision" | "cacheProvenance"> & {
104
104
  attempts?: readonly PersistedUsageAttempt[];
105
105
  };
106
106
 
@@ -129,10 +129,11 @@ export function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usa
129
129
  export const MIN_DECODE_WINDOW_MS = 1_000;
130
130
 
131
131
  /**
132
- * Estimated DECODE throughput: output tokens over the window after the first token (#4038).
132
+ * Estimated DECODE throughput: output tokens over the measured output window (#4038): the generation window when
133
+ * recorded, otherwise the window after the first token.
133
134
  *
134
- * Strictly additive. `tokensPerSecond`, `tokPerSecondResult`, `RequestLogEntry` and
135
- * `usage.jsonl` are untouched, and the end-to-end rate beside it keeps meaning exactly what it
135
+ * Strictly additive. `tokensPerSecond` and `tokPerSecondResult` are untouched, rows only gain the
136
+ * optional window pair, and the end-to-end rate beside it keeps meaning exactly what it
136
137
  * has always meant — it is documented as end-to-end, so this is a missing metric rather than a
137
138
  * miscalculated one.
138
139
  *
@@ -140,13 +141,26 @@ export const MIN_DECODE_WINDOW_MS = 1_000;
140
141
  * not the provider's own generation start, so this can never be more than an estimate no matter
141
142
  * how long the window is. Saying so in the payload is the honest half of the answer to #4040;
142
143
  * MIN_DECODE_WINDOW_MS is the other half.
144
+ *
145
+ * When the row carries the generation window (`genStartMs` / `lastOutputMs`, #6309) that window is
146
+ * used instead. `firstOutputMs` is the first VISIBLE delta, so on a reasoning model the post-TTFT
147
+ * window leaves out the reasoning phase while `outputTokens` still counts its tokens; the
148
+ * generation window spans both and stops at the last delta rather than at stream close.
143
149
  */
144
150
  export function decodeTokPerSecondResult(
145
- entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "usageStatus" | "usage">,
151
+ entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "genStartMs" | "lastOutputMs" | "usageStatus" | "usage">,
146
152
  ): TokPerSecondResult {
147
153
  if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
148
154
  if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
149
155
  if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" };
156
+ const { genStartMs, lastOutputMs } = entry;
157
+ if (genStartMs !== undefined && lastOutputMs !== undefined && Number.isFinite(genStartMs) && Number.isFinite(lastOutputMs)) {
158
+ const generationMs = lastOutputMs - genStartMs;
159
+ if (generationMs <= 0) return { kind: "unavailable", reason: "invalid_duration" };
160
+ if (generationMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
161
+ const value = tokensPerSecond(entry.usage.outputTokens, generationMs);
162
+ return value === null ? { kind: "unavailable", reason: "invalid_duration" } : { kind: "value", value, estimated: true };
163
+ }
150
164
  // A row that predates TTFT capture, or a non-streaming turn that never recorded one, has no
151
165
  // window to measure. That is a different fact from a bad duration, so it gets its own reason.
152
166
  if (entry.firstOutputMs === undefined) return { kind: "unavailable", reason: "ttft_missing" };
@@ -0,0 +1,214 @@
1
+ import type { ManagementContext } from "./context";
2
+ import { catalogModelSlug, filterCatalogVisibleModels, nativeModelRows, listCatalogNativeSlugs } from "../../codex/catalog";
3
+ import { subagentSelectableModels } from "../../codex/subagent-selectable-models";
4
+ import { saveConfigPreservingClaudeCode, deleteConfigTopLevelKey, mutatePersistedConfig, adoptPersistedClaudeCode, initializePersistedConfigIfMissing, observeInitialConfigState } from "../../config";
5
+ import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance, deleteConfigObjectChildKey } from "../../config/rebase-provenance";
6
+ import { InitialConfigPublicationError } from "../../config/initialize";
7
+ import { ConfigWritePublishedError } from "../../config/persist-unlocked";
8
+ import { commitClaudeCodeBlock } from "../../claude/claude-code-block";
9
+ import { resolveSubagentForceModel, SAFE_AGENT_MODEL_ID } from "../../claude/subagent-model";
10
+ import { inspectSubagentForceStatus } from "../../claude/subagent-force-status";
11
+ import { jsonResponse } from "../auth-cors";
12
+ import { isPlainRecord, fetchInitializedModels as fetchAllModels } from "./shared";
13
+ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
14
+
15
+ export async function handleSubagentModelRoutes(ctx: ManagementContext, autoApplyDesktopBestEffort: () => Promise<void>): Promise<Response | null> {
16
+ const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
17
+ // Featured roster and saved picker order are separate settings. Native Codex advertises
18
+ // the first five eligible visible rows by display priority; OCX guidance uses natural ranks.
19
+ if (url.pathname === "/api/subagent-models" && req.method === "GET") {
20
+ const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
21
+ // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
22
+ // catalog, just buried by priority. List them first so the user can feature them over routed.
23
+ const chosen = config.subagentModels ?? [];
24
+ // A saved roster slot must stay representable even after its model is disabled
25
+ // elsewhere (Models page, provider allowlist, a provider row going away). The
26
+ // dashboard treats `available` as the set of rows it can render, so a chosen id
27
+ // missing from it disappears from the roster UI and the next Save — which PUTs
28
+ // exactly what the UI holds — silently truncates the persisted list. Losing a
29
+ // deliberate 5-model roster to an unrelated visibility toggle is data loss, not a
30
+ // filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a
31
+ // model the user already excluded so its switch remains reachable.
32
+ const available = subagentSelectableModels(config, models, listCatalogNativeSlugs());
33
+ // #857: let CLI/GUI show when a running Codex app-server keeps an older
34
+ // in-memory catalog than the one on disk. Bounded request-path read: the synchronous
35
+ // collector blocked the event loop for the whole Windows CIM walk (4-7s measured).
36
+ const {
37
+ collectCodexAppServerCatalogStateWithin,
38
+ DASHBOARD_CATALOG_STATE_DEADLINE_MS,
39
+ } = await import("../../codex/app-server-processes");
40
+ const catalogState = await collectCodexAppServerCatalogStateWithin(DASHBOARD_CATALOG_STATE_DEADLINE_MS);
41
+ const forceAvailable = subagentSelectableModels({ ...config, subagentModels: [] }, filterCatalogVisibleModels(models, config), listCatalogNativeSlugs());
42
+ return jsonResponse({
43
+ chosen, available, catalogState,
44
+ force: config.claudeCode?.subagentModelForce ?? null,
45
+ forceAvailable,
46
+ forceStatus: config.claudeCode?.subagentModelForce === undefined ? null : await inspectSubagentForceStatus(
47
+ resolveSubagentForceModel(config, {}, { entries: forceAvailable }) !== null,
48
+ ),
49
+ pickerAvailable: [...new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")))],
50
+ pickerOrder: config.modelPickerOrder ?? [],
51
+ pickerOrderMode: config.modelPickerOrderMode ?? null,
52
+ });
53
+ }
54
+ if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
55
+ // Observe before body/discovery awaits: disappearance of an existing config
56
+ // must never turn a scoped update into first-run initialization.
57
+ const initialConfigState = deps.saveConfigPreservingClaudeCode ? undefined : observeInitialConfigState();
58
+ let rawBody: unknown;
59
+ try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
60
+ if (!isPlainRecord(rawBody)) return jsonResponse({ error: "JSON body must be an object" }, 400);
61
+ const body = rawBody as { models?: unknown; pickerOrder?: unknown; pickerOrderMode?: unknown; force?: unknown };
62
+ const updatesForce = Object.hasOwn(body, "force");
63
+ const updatesRoster = body.models !== undefined;
64
+ const updatesPicker = body.pickerOrder !== undefined;
65
+ if (!updatesRoster && !updatesPicker && !updatesForce) return jsonResponse({ error: "models, pickerOrder or force is required" }, 400);
66
+ let chosen: string[] | undefined;
67
+ if (updatesRoster) {
68
+ if (!Array.isArray(body.models) || body.models.some(model => typeof model !== "string")) {
69
+ return jsonResponse({ error: "models must be an array of strings" }, 400);
70
+ }
71
+ // Keep the original valid roster contract: no discovery validation, trimming or deduping.
72
+ chosen = body.models.slice(0, 5);
73
+ }
74
+ const mode = body.pickerOrderMode;
75
+ if (mode !== undefined && (!updatesPicker || (mode !== null
76
+ && mode !== "alphabetical" && mode !== "provider" && mode !== "most-used"))) {
77
+ return jsonResponse({ error: "pickerOrderMode requires pickerOrder and must be alphabetical, provider, most-used, or null" }, 400);
78
+ }
79
+ let pickerOrder: string[] | undefined;
80
+ if (updatesPicker) {
81
+ if (body.pickerOrder !== null && (!Array.isArray(body.pickerOrder)
82
+ || body.pickerOrder.some(model => typeof model !== "string" || model.trim() === ""))) {
83
+ return jsonResponse({ error: "pickerOrder must be an array of non-empty routed model ids, or null" }, 400);
84
+ }
85
+ pickerOrder = body.pickerOrder === null ? [] : (body.pickerOrder as string[]).map(model => model.trim());
86
+ if (new Set(pickerOrder).size !== pickerOrder.length) {
87
+ return jsonResponse({ error: "pickerOrder must not contain duplicate ids" }, 400);
88
+ }
89
+ if (pickerOrder.length > 0) {
90
+ const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
91
+ // Evaluate visibility AFTER discovery: a concurrent visibility write may have completed.
92
+ const visible = new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")));
93
+ // A bare native id orders the complete picker (docs: guides/model-ordering.md).
94
+ for (const row of nativeModelRows(config)) if (!row.disabled) visible.add(row.slug);
95
+ if (pickerOrder.some(model => !visible.has(model))) {
96
+ return jsonResponse({ error: "pickerOrder must contain visible routed or native model ids, each at most once" }, 400);
97
+ }
98
+ }
99
+ }
100
+
101
+ if (updatesForce && body.force !== null) {
102
+ if (typeof body.force !== "string" || !SAFE_AGENT_MODEL_ID.test(body.force)) {
103
+ return jsonResponse({ error: "force must be a safe exposed model id or null" }, 400);
104
+ }
105
+ const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
106
+ const exposed = subagentSelectableModels({ ...config, subagentModels: [] }, filterCatalogVisibleModels(models, config), listCatalogNativeSlugs());
107
+ if (!resolveSubagentForceModel({ ...config, claudeCode: { ...config.claudeCode, subagentModelForce: body.force } }, {}, { entries: exposed })) {
108
+ return jsonResponse({ error: "force must resolve to an exposed model" }, 400);
109
+ }
110
+ }
111
+
112
+ if (updatesForce && initialConfigState === "invalid") {
113
+ return jsonResponse({ error: "force settings could not be persisted" }, 409);
114
+ }
115
+
116
+ // Everything above can await. From this snapshot through persistence there is no yield.
117
+ // Stage deletion intent before adopting the touched fields through the canonical
118
+ // live deletion owner. A failed save restores both fields and pending intent.
119
+ if ((updatesPicker || updatesForce) && config.configRebaseProvenance !== undefined
120
+ && parsedConfigRebaseDeletionKeys(config) === null) {
121
+ // A newer provenance format must not silently discard this clear's intent on rebase.
122
+ return jsonResponse({ error: "unsupported config deletion provenance" }, 409);
123
+ }
124
+ const draft = { ...projectConfigRebaseProvenance(config) };
125
+ if (chosen !== undefined) draft.subagentModels = chosen;
126
+ if (updatesForce) {
127
+ commitClaudeCodeBlock(draft, { ...config.claudeCode });
128
+ if (body.force === null) deleteConfigObjectChildKey(draft, "claudeCode", "subagentModelForce");
129
+ else draft.claudeCode!.subagentModelForce = body.force as string;
130
+ }
131
+ if (pickerOrder !== undefined) {
132
+ if (pickerOrder.length === 0) {
133
+ deleteConfigTopLevelKey(draft, "modelPickerOrder");
134
+ deleteConfigTopLevelKey(draft, "modelPickerOrderMode");
135
+ } else {
136
+ draft.modelPickerOrder = pickerOrder;
137
+ if (mode === "alphabetical" || mode === "provider" || mode === "most-used") draft.modelPickerOrderMode = mode;
138
+ else deleteConfigTopLevelKey(draft, "modelPickerOrderMode");
139
+ }
140
+ }
141
+ const projected = projectConfigRebaseProvenance(draft);
142
+ const touched = [
143
+ ...(updatesRoster ? ["subagentModels" as const] : []),
144
+ ...(updatesForce ? ["claudeCode" as const] : []),
145
+ ...(updatesPicker ? ["modelPickerOrder" as const, "modelPickerOrderMode" as const] : []),
146
+ "configRebaseProvenance" as const,
147
+ ];
148
+ const rollback = captureConfigTopLevelRollback(config, touched);
149
+ try {
150
+ for (const key of touched) {
151
+ if (Object.hasOwn(projected, key)) Object.defineProperty(config, key, {
152
+ value: projected[key], writable: true, enumerable: true, configurable: true,
153
+ });
154
+ else deleteConfigTopLevelKey(config, key);
155
+ }
156
+ if (updatesForce && body.force === null) deleteConfigObjectChildKey(config, "claudeCode", "subagentModelForce");
157
+ if (updatesForce && initialConfigState === "missing") {
158
+ // Create-only publication rechecks under the canonical lock and never
159
+ // replaces a file another writer supplied while discovery was pending.
160
+ if (initializePersistedConfigIfMissing(config) !== "created") {
161
+ rollback();
162
+ return jsonResponse({ error: "force settings could not be persisted" }, 409);
163
+ }
164
+ adoptPersistedClaudeCode(config, structuredClone(config.claudeCode));
165
+ } else if (updatesForce && !deps.saveConfigPreservingClaudeCode) {
166
+ const outcome = mutatePersistedConfig(persisted => {
167
+ commitClaudeCodeBlock(persisted, { ...persisted.claudeCode });
168
+ if (body.force === null) delete persisted.claudeCode!.subagentModelForce;
169
+ else persisted.claudeCode!.subagentModelForce = body.force as string;
170
+ if (updatesRoster) persisted.subagentModels = chosen!;
171
+ if (updatesPicker) {
172
+ if (pickerOrder!.length === 0) {
173
+ deleteConfigTopLevelKey(persisted, "modelPickerOrder");
174
+ deleteConfigTopLevelKey(persisted, "modelPickerOrderMode");
175
+ } else {
176
+ persisted.modelPickerOrder = pickerOrder;
177
+ if (mode === "alphabetical" || mode === "provider" || mode === "most-used") persisted.modelPickerOrderMode = mode;
178
+ else deleteConfigTopLevelKey(persisted, "modelPickerOrderMode");
179
+ }
180
+ }
181
+ return { changed: true, value: structuredClone(persisted.claudeCode) };
182
+ });
183
+ if (outcome.status === "unavailable") {
184
+ rollback();
185
+ return jsonResponse({ error: "force settings could not be persisted" }, 409);
186
+ }
187
+ adoptPersistedClaudeCode(config, outcome.value);
188
+ } else {
189
+ (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
190
+ }
191
+ } catch (error) {
192
+ // Once publication succeeded (or cannot be ruled out), restoring only the
193
+ // live object would falsely report the old setting while disk may hold new bytes.
194
+ if (!(error instanceof ConfigWritePublishedError)
195
+ && !(error instanceof InitialConfigPublicationError && error.publication !== "not-published")) rollback();
196
+ throw error;
197
+ }
198
+ // Capture the result before convergence yields to another settings mutation.
199
+ const saved = {
200
+ applied: [...(config.subagentModels ?? [])],
201
+ force: config.claudeCode?.subagentModelForce ?? null,
202
+ pickerOrder: [...(config.modelPickerOrder ?? [])],
203
+ pickerOrderMode: config.modelPickerOrderMode ?? null,
204
+ };
205
+ const catalogRefresh = updatesRoster || updatesPicker ? await convergeCodexCatalog() : undefined;
206
+ if (updatesRoster) {
207
+ await syncClaudeAgentDefsBestEffort();
208
+ await autoApplyDesktopBestEffort();
209
+ }
210
+ return jsonResponse({ ok: true, ...saved, catalogRefresh });
211
+ }
212
+
213
+ return null;
214
+ }
@@ -30,8 +30,9 @@ import {
30
30
  joinSseFrameBytes,
31
31
  MAX_CLIENT_SSE_FRAME_BYTES,
32
32
  SseFrameCountLimitError,
33
+ sseDelimiterLengthAt,
33
34
  } from "./sse-frame-buffer";
34
- import { replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite";
35
+ import { replaceSseDataPayload, splitSseBlock, sseDataPayload } from "./sse-payload-rewrite";
35
36
  import { createBoundedResponseLogBody } from "./response-log-body";
36
37
  import { clientWireLogOf } from "./inference/client-wire";
37
38
  import { recordClientWireRequestLog } from "./inference/client-wire-log";
@@ -355,10 +356,11 @@ export function createSseTerminalOutputBoundary(
355
356
  frames: ReturnType<BoundedSseFrameBuffer["feed"]>,
356
357
  ): Uint8Array => {
357
358
  if (disposed || terminal || frames.length === 0) return EMPTY_BYTES;
358
- if (maxFrames !== undefined && frames.length > maxFrames - framesSeen) {
359
+ const completedFrames = frames.reduce((count, frame) => count + (frame.continuation ? 0 : 1), 0);
360
+ if (maxFrames !== undefined && completedFrames > maxFrames - framesSeen) {
359
361
  throw new SseFrameCountLimitError(maxFrames);
360
362
  }
361
- framesSeen += frames.length;
363
+ framesSeen += completedFrames;
362
364
  const output: Uint8Array[] = [];
363
365
  const appendOutput = (...parts: Uint8Array[]): void => {
364
366
  for (const part of parts) {
@@ -596,17 +598,7 @@ export function relaySseWithFailedTail(
596
598
  });
597
599
  }
598
600
 
599
- export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
600
- const match = buffer.match(/\r?\n\r?\n/);
601
- if (!match || match.index === undefined) return null;
602
- return {
603
- block: buffer.slice(0, match.index),
604
- delimiter: match[0],
605
- rest: buffer.slice(match.index + match[0].length),
606
- };
607
- }
608
-
609
- export { sseDataPayload } from "./sse-payload-rewrite";
601
+ export { nextSseBlock, sseDataPayload } from "./sse-payload-rewrite";
610
602
 
611
603
  type JsonRecord = Record<string, unknown>;
612
604
 
@@ -698,9 +690,8 @@ function stripCodexSafetyBufferingField(block: string, parsed: unknown): string
698
690
  }
699
691
 
700
692
  function rewritePolicyTerminalBlock(block: string, payload: string): string {
701
- const newline = block.includes("\r\n") ? "\r\n" : "\n";
702
693
  const rewritten = replaceSseDataPayload(block, payload);
703
- const lines = rewritten.split(/\r?\n/);
694
+ const { newline, lines } = splitSseBlock(rewritten);
704
695
  let eventRewritten = false;
705
696
  const withEvent = lines.map(line => {
706
697
  if (!eventRewritten && line.startsWith("event:")) {
@@ -1156,31 +1147,6 @@ function outputBearingSseEvent(event: { type?: unknown; output_index?: unknown }
1156
1147
  .test(event.type);
1157
1148
  }
1158
1149
 
1159
- function delimiterLengthAt(
1160
- index: number,
1161
- length: number,
1162
- byteAt: (index: number) => number,
1163
- ): number | 0 | undefined {
1164
- const first = byteAt(index);
1165
- if (first === 10) {
1166
- if (index + 1 >= length) return undefined;
1167
- const second = byteAt(index + 1);
1168
- if (second === 10) return 2;
1169
- if (second !== 13) return 0;
1170
- if (index + 2 >= length) return undefined;
1171
- return byteAt(index + 2) === 10 ? 3 : 0;
1172
- }
1173
- if (first !== 13) return 0;
1174
- if (index + 1 >= length) return undefined;
1175
- if (byteAt(index + 1) !== 10) return 0;
1176
- if (index + 2 >= length) return undefined;
1177
- const third = byteAt(index + 2);
1178
- if (third === 10) return 3;
1179
- if (third !== 13) return 0;
1180
- if (index + 3 >= length) return undefined;
1181
- return byteAt(index + 3) === 10 ? 4 : 0;
1182
- }
1183
-
1184
1150
  /**
1185
1151
  * Per-chunk SSE inspection state machine shared by consumeForInspection,
1186
1152
  * consumeForResponseLogMetadata, and the eager bounded relay (relay-eager.ts).
@@ -1202,6 +1168,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
1202
1168
  let sawTerminal = false;
1203
1169
  let disposed = false;
1204
1170
  let delimiterTail: Uint8Array = EMPTY_BYTES;
1171
+ let pendingLineFeed = false;
1205
1172
  let candidate: Uint8Array = EMPTY_BYTES;
1206
1173
  let candidateBytes = 0;
1207
1174
  let discardingOversizedFrame = false;
@@ -1228,6 +1195,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
1228
1195
 
1229
1196
  const clearFrameState = (): void => {
1230
1197
  delimiterTail = EMPTY_BYTES;
1198
+ pendingLineFeed = false;
1231
1199
  candidate = EMPTY_BYTES;
1232
1200
  candidateBytes = 0;
1233
1201
  discardingOversizedFrame = false;
@@ -1504,15 +1472,17 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
1504
1472
  retainCandidateSlice(chunk.subarray(Math.max(0, start - tailLength), end - tailLength));
1505
1473
  }
1506
1474
  };
1507
- let index = 0;
1508
- let retainedThrough = 0;
1475
+ let index = pendingLineFeed && byteAt(0) === 10 ? 1 : 0;
1476
+ if (totalLength > 0) pendingLineFeed = false;
1477
+ let retainedThrough = index;
1509
1478
  while (index < totalLength) {
1510
- const delimiterLength = delimiterLengthAt(index, totalLength, byteAt);
1479
+ const delimiterLength = sseDelimiterLengthAt(index, totalLength, byteAt);
1511
1480
  if (delimiterLength === undefined) break;
1512
1481
  if (delimiterLength > 0) {
1513
1482
  retainRange(retainedThrough, index);
1514
1483
  completeCandidate();
1515
1484
  index += delimiterLength;
1485
+ pendingLineFeed = index === totalLength && byteAt(index - 1) === 13;
1516
1486
  retainedThrough = index;
1517
1487
  continue;
1518
1488
  }
@@ -0,0 +1,38 @@
1
+ import type { RequestLogContext } from "./request-log";
2
+
3
+ /**
4
+ * Generation window for the decode-rate estimate (#4038, #6309).
5
+ *
6
+ * `firstOutputMs` marks the first VISIBLE output delta. For a reasoning model that is the end of
7
+ * the reasoning phase, yet `outputTokens` includes the reasoning tokens, so
8
+ * `outputTokens / (durationMs - firstOutputMs)` divides the whole output by only part of the time
9
+ * that produced it. The window recorded here starts when the model starts producing ANY output
10
+ * item (reasoning included) and ends at the last output delta, so trailing stream bookkeeping
11
+ * after the final token does not count either.
12
+ *
13
+ * Event vocabulary: Anthropic `content_block_start` / `content_block_delta`, and Responses
14
+ * `response.output_item.added` / `response.*.delta`. Anything else leaves the window untouched.
15
+ */
16
+ export function recordGenerationEvent(logCtx: RequestLogContext, type: unknown, now = Date.now()): void {
17
+ if (typeof type !== "string" || !Number.isFinite(now)) return;
18
+ if (type === "content_block_start" || type === "response.output_item.added") {
19
+ logCtx.generationStartedAt ??= now;
20
+ } else if (type === "content_block_delta" || (type.startsWith("response.") && type.endsWith(".delta"))) {
21
+ logCtx.generationStartedAt ??= now;
22
+ logCtx.lastOutputAt = now;
23
+ }
24
+ }
25
+
26
+ /** Request-relative `genStartMs` / `lastOutputMs`, present only when both ends were observed. */
27
+ export function generationWindowFields(
28
+ logCtx: Pick<RequestLogContext, "generationStartedAt" | "lastOutputAt">,
29
+ requestStartedAt: number,
30
+ ): { genStartMs?: number; lastOutputMs?: number } {
31
+ const startedAt = logCtx.generationStartedAt;
32
+ const lastAt = logCtx.lastOutputAt;
33
+ if (startedAt === undefined || lastAt === undefined || !Number.isFinite(requestStartedAt)) return {};
34
+ return {
35
+ genStartMs: Math.max(0, startedAt - requestStartedAt),
36
+ lastOutputMs: Math.max(0, lastAt - requestStartedAt),
37
+ };
38
+ }
@@ -85,6 +85,7 @@ import {
85
85
  normalizePersistedJevDecision,
86
86
  type PersistedJevDecisionV1,
87
87
  } from "../usage/jev-stats";
88
+ import { generationWindowFields, recordGenerationEvent } from "./request-log-generation-window";
88
89
  import type { RequestMetricsRecorder } from "./request-metrics";
89
90
  import type {
90
91
  CacheDiagnosticDraft,
@@ -136,6 +137,9 @@ export interface RequestLogContext {
136
137
  affinityMoveReasons?: CodexAffinityReason[];
137
138
  /** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
138
139
  firstOutputMs?: number;
140
+ /** Epoch ms of the first output item and of the last output delta; see request-log-generation-window.ts. */
141
+ generationStartedAt?: number;
142
+ lastOutputAt?: number;
139
143
  /** Best-effort chat/session correlation for Logs grouping (#330). Opaque; omit when unknown. */
140
144
  conversationId?: string;
141
145
  surface?: "claude" | "claude-desktop" | "grok";
@@ -282,6 +286,9 @@ export interface RequestLogEntry {
282
286
  provider: string;
283
287
  /** TTFT: ms from request start to the first non-empty model output delta; unset for non-streaming/tool-only. */
284
288
  firstOutputMs?: number;
289
+ /** Request-relative generation window (#6309): first output item of any kind, then the last output delta. */
290
+ genStartMs?: number;
291
+ lastOutputMs?: number;
285
292
  surface?: "claude" | "claude-desktop" | "grok";
286
293
  /**
287
294
  * Set when the proxy answered this turn locally and sent nothing upstream. Without it a zero-send
@@ -453,6 +460,8 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
453
460
  model: entry.model,
454
461
  provider: entry.provider,
455
462
  ...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
463
+ ...(entry.genStartMs !== undefined ? { genStartMs: entry.genStartMs } : {}),
464
+ ...(entry.lastOutputMs !== undefined ? { lastOutputMs: entry.lastOutputMs } : {}),
456
465
  ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}),
457
466
  ...(entry.conversationId ? { conversationId: entry.conversationId } : {}),
458
467
  ...(isCodexUsageAccountLogLabel(entry.accountLogLabel)
@@ -668,6 +677,8 @@ export function addRequestLog(entry: RequestLogEntry) {
668
677
  status: entry.status,
669
678
  durationMs: entry.durationMs,
670
679
  ...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
680
+ ...(entry.genStartMs !== undefined ? { genStartMs: entry.genStartMs } : {}),
681
+ ...(entry.lastOutputMs !== undefined ? { lastOutputMs: entry.lastOutputMs } : {}),
671
682
  usageStatus: entry.usageStatus,
672
683
  ...(entry.usage ? { usage: entry.usage } : {}),
673
684
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
@@ -1056,6 +1067,7 @@ export function inspectResponseLogSsePayloadParsed(
1056
1067
  if (!payload || payload.trim() === "[DONE]") return;
1057
1068
  const debugEnabled = isUsageDebugEnabled();
1058
1069
  const sseAlreadyMarked = logCtx.usageDebugBodyKind === "sse";
1070
+ if (parsed !== undefined) recordGenerationEvent(logCtx, (parsed as { type?: unknown } | null)?.type);
1059
1071
  if (parsed !== undefined) applyResponseLogMetadata(logCtx, parsed);
1060
1072
  else logCtx.activeTierMetadata?.markResponseUnparseable();
1061
1073
  captureUpstreamErrorParsed(logCtx, payload, parsed);
@@ -1571,6 +1583,7 @@ export function addFinalRequestLog(
1571
1583
  status: effectiveStatus,
1572
1584
  durationMs,
1573
1585
  ...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
1586
+ ...generationWindowFields(logCtx, start),
1574
1587
  ...(errorCode ? { errorCode } : {}),
1575
1588
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
1576
1589
  ...(closeReason ? { closeReason } : {}),
@@ -46,9 +46,10 @@ import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
46
46
  import type { AttemptRecoveryKind } from "../../usage/log";
47
47
  import type { OAuthAccessSnapshot } from "../../oauth";
48
48
  import { OAuthAccountPausedError, OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth";
49
- import { getAccountSet } from "../../oauth/store";
49
+ import { getAccountSet, markAccountNeedsReauthIfGeneration } from "../../oauth/store";
50
50
  import { tryKiroAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover";
51
51
  import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
52
+ import { classifyAntigravityRefusal } from "../../adapters/antigravity-refusal";
52
53
  import { normalizeFinalKiroHttpError } from "../../adapters/kiro-retry";
53
54
  import { noteKiroMonthlyRefusal } from "../../providers/kiro-usage";
54
55
  import { persistKiroAccountState } from "../../providers/kiro-account-state-disk";
@@ -1243,6 +1244,104 @@ export async function prepareAdapterExchange(
1243
1244
  break;
1244
1245
  }
1245
1246
  }
1247
+ // Antigravity verify-account quarantine: a 403 demanding account verification
1248
+ // is terminal for THAT account — its grant still refreshes, so only a manual
1249
+ // verification followed by a re-login clears it. Mark it needsReauth (durable,
1250
+ // shown as `needs-reauth(verify)` in `ocx account list` with a `verify_account`
1251
+ // health reason in the management API, excluded from the pool until re-login)
1252
+ // and replay the same request on the next eligible account so the pool keeps
1253
+ // serving.
1254
+ while (
1255
+ upstreamResponse.status === 403
1256
+ && route.providerName === "google-antigravity"
1257
+ && transportState.genericFailoverAccountId
1258
+ ) {
1259
+ const refusal = classifyAntigravityRefusal(upstreamResponse.status,
1260
+ await readDisplaySafeErrorText(upstreamResponse.clone(), upstream.signal, ""));
1261
+ if (refusal.kind === "other") break;
1262
+ const sent = transportState.sentOAuthSnapshot;
1263
+ const failedAccountId = transportState.genericFailoverAccountId;
1264
+ // Fenced write: a 403 arriving after the credential rotated (refresh or
1265
+ // re-login) must not quarantine the new login. Without the matching sent
1266
+ // snapshot there is no owner for this write, so skip marking.
1267
+ if (!sent || sent.accountId !== failedAccountId) break;
1268
+ try {
1269
+ await markAccountNeedsReauthIfGeneration(route.providerName, failedAccountId, sent.generation, undefined, "verify_account");
1270
+ } catch {
1271
+ // A failed quarantine write must preserve the refusal, not authorize another send.
1272
+ break recovery;
1273
+ }
1274
+ if (!antigravityPoolActivated || transportState.genericFailovers >= transportState.genericFailoverLimit) break;
1275
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
1276
+ const hop = reserveCredentialHop(
1277
+ "auth-recovery",
1278
+ `${route.providerName}|${route.modelId}|adapter-recovery-oauth-verify`,
1279
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
1280
+ );
1281
+ if (!hop.allowed) break;
1282
+ // Auth-refusal rotation, not the rate-limit one: a verification refusal
1283
+ // must not record rate-limit cooldown semantics against the account.
1284
+ const nextAccountId = rotateAntigravityAccountOnAuthRefusal(
1285
+ antigravityPoolActivated,
1286
+ failedAccountId,
1287
+ sent.generation,
1288
+ route.modelId,
1289
+ );
1290
+ if (!nextAccountId) {
1291
+ hop.permit?.release();
1292
+ break;
1293
+ }
1294
+ // The refusal body stays alive until the replacement owns the outcome: it
1295
+ // is also the preserveFailureResponse below, so cancelling it early would
1296
+ // hand back a 403 with a destroyed body when the rebuild is refused.
1297
+ const failedResponse = upstreamResponse;
1298
+ try {
1299
+ // The FULL snapshot, not just the bearer: Antigravity pairs an
1300
+ // account-matched projectId with its token, so a token-only swap would mix
1301
+ // one account's credential with another's routing data.
1302
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
1303
+ if (!await applyFailoverSnapshot(snapshot)) {
1304
+ hop.permit?.release();
1305
+ break;
1306
+ }
1307
+ invalidateSameTargetRequest();
1308
+ transportState.activeAdapter = resolveSelectionAdapter(
1309
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
1310
+ config.cacheRetention,
1311
+ );
1312
+ bindRouteReasoningReplayScope({
1313
+ parsed,
1314
+ providerName: route.providerName,
1315
+ provider: route.provider,
1316
+ adapterName: transportState.activeAdapter.name,
1317
+ oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
1318
+ });
1319
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
1320
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
1321
+ sendBudgetState.pendingHopPermit = hop.permit;
1322
+ let result: Response | { failed: Response };
1323
+ try {
1324
+ result = await rebuildAndRefetch("oauth-account-403", () => {
1325
+ if (!adapterOwnsDispatch) hop.permit?.use();
1326
+ }, failedResponse);
1327
+ } finally {
1328
+ sendBudgetState.pendingHopPermit = undefined;
1329
+ }
1330
+ if ("failed" in result) {
1331
+ hop.permit?.release();
1332
+ if (result.failed !== failedResponse) return result.failed;
1333
+ // Preserve common redaction and combo consumption without another recovery send.
1334
+ break recovery;
1335
+ }
1336
+ transportState.genericFailovers += 1;
1337
+ try { void failedResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1338
+ upstreamResponse = result;
1339
+ if (isNonReplayableResponse(upstreamResponse)) continue recovery;
1340
+ } catch {
1341
+ hop.permit?.release();
1342
+ break;
1343
+ }
1344
+ }
1246
1345
  }
1247
1346
  // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
1248
1347
  // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is