@bitkyc08/opencodex 2.63.0-preview.20260923 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-B03pW-Uf.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-EJxiUFMq.js +0 -50
  259. package/gui/dist/assets/index-BmJwNBHL.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-IftE60UK.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -12,6 +12,12 @@
12
12
  import { loadConfig } from "../config";
13
13
  import { isWildcardHostname } from "../codex/loopback-target";
14
14
  import { readAlivePid, readRuntimePort, verifyPidIdentity } from "../config/process-state";
15
+ import {
16
+ LOCAL_ATTESTATION_CHALLENGE_HEADER,
17
+ LOCAL_ATTESTATION_PROOF_HEADER,
18
+ createLocalAttestationChallenge,
19
+ verifyLocalAttestationProof,
20
+ } from "../lib/local-management-attestation";
15
21
  import { directLocalHttpFetch } from "./direct-local-http";
16
22
 
17
23
  export interface HealthzIdentity {
@@ -29,7 +35,11 @@ export interface HealthzIdentity {
29
35
  role?: unknown;
30
36
  restartCapability?: unknown;
31
37
  providerReloadCapability?: unknown;
38
+ asideSyncCapability?: unknown;
32
39
  guiPairCapability?: unknown;
40
+ /** Present on a package-tree-fenced 503: the version on disk an in-place respawn will run. */
41
+ installedVersion?: unknown;
42
+ error?: unknown;
33
43
  }
34
44
 
35
45
  export type EndpointLiveness = "live" | "dead" | "unknown";
@@ -60,6 +70,18 @@ export interface LivenessIo {
60
70
  */
61
71
  deadlineAt?: number;
62
72
  nowFn?: () => number;
73
+ /**
74
+ * Also accept a proxy whose `/healthz` is fenced by the package-tree guard (#5496).
75
+ *
76
+ * Opt-in, because such a proxy is ours but not healthy: `ocx restart` and the stop paths
77
+ * must find it, while ensure, update health waits and replacement waits must not count it.
78
+ * A fenced body is never trusted by itself. It is accepted only when this home's runtime
79
+ * record names the same pid and port and the listener proves possession of that record's
80
+ * attestation secret for a fresh challenge.
81
+ */
82
+ acceptPackageTreeFenced?: boolean;
83
+ /** Test seam for the fenced-identity challenge. */
84
+ createChallengeFn?: () => string;
63
85
  }
64
86
 
65
87
  /** Default per-probe fetch ceiling shared by liveness and readiness probes. */
@@ -117,6 +139,12 @@ export interface LiveProxy {
117
139
  * on. Absent for a proxy whose healthz body predates the field.
118
140
  */
119
141
  role?: string;
142
+ /**
143
+ * The proxy answered with an attested package-tree fence (#5496): it is this home's process,
144
+ * but it is refusing traffic until it restarts. Only set for callers that opted in through
145
+ * `LivenessIo.acceptPackageTreeFenced`.
146
+ */
147
+ packageTreeFenced?: true;
120
148
  }
121
149
 
122
150
  /**
@@ -155,6 +183,64 @@ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean {
155
183
  return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number";
156
184
  }
157
185
 
186
+ /**
187
+ * True for the exact 503 body the package-tree guard serves on `/healthz`. Shape only: this is
188
+ * what the listener CLAIMS, and whoever holds the port can claim it. Callers must attest it.
189
+ */
190
+ export function isPackageTreeFencedHealthz(body: HealthzIdentity | null | undefined): boolean {
191
+ if (!body || body.service !== "opencodex" || body.status !== "restart_required") return false;
192
+ const error = body.error as { code?: unknown } | null | undefined;
193
+ return typeof error === "object" && error !== null && error.code === "package_tree_changed"
194
+ && typeof body.pid === "number" && Number.isSafeInteger(body.pid) && body.pid > 0;
195
+ }
196
+
197
+ /**
198
+ * Prove that a fenced listener is the process this home's runtime record describes: the record
199
+ * must name the same pid and port and carry an attestation secret, and the listener must answer a
200
+ * fresh challenge with a proof bound to that secret, pid and port. The pid in the 503 body only
201
+ * selects which record to check; it is never accepted on its own.
202
+ */
203
+ async function attestFencedIdentity(
204
+ url: string,
205
+ port: number,
206
+ pid: number,
207
+ io: LivenessIo,
208
+ fetchFn: LivenessFetch,
209
+ timeoutMs: number,
210
+ ): Promise<boolean> {
211
+ const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort;
212
+ let record: ReturnType<NonNullable<LivenessIo["readRuntimeFn"]>>;
213
+ try {
214
+ record = readRuntimeFn(pid);
215
+ } catch {
216
+ return false;
217
+ }
218
+ // The typed seam omits the secret; the production record (readRuntimePort) carries it.
219
+ const secret: unknown = record ? Reflect.get(record, "attestationSecret") : undefined;
220
+ if (!record || record.pid !== pid || record.port !== port || typeof secret !== "string") return false;
221
+ const challenge = (io.createChallengeFn ?? createLocalAttestationChallenge)();
222
+ try {
223
+ const res = await fetchFn(url, {
224
+ headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge },
225
+ signal: AbortSignal.timeout(timeoutMs),
226
+ });
227
+ const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
228
+ // The second answer must still be the same fenced (or by now healthy) process.
229
+ if (!isOpencodexHealthz(body) && !isPackageTreeFencedHealthz(body)) return false;
230
+ if (body?.pid !== pid) return false;
231
+ return verifyLocalAttestationProof(secret, challenge, pid, port, res.headers.get(LOCAL_ATTESTATION_PROOF_HEADER));
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ /** A bounded version string safe to carry beyond the untrusted health response. */
238
+ export function isHealthzVersion(value: unknown): value is string {
239
+ return typeof value === "string"
240
+ && value.length <= 64
241
+ && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
242
+ }
243
+
158
244
  /**
159
245
  * "Nothing is listening" is narrower than "the probe failed". Only a connect-phase refusal
160
246
  * proves the endpoint is free; a timeout, reset, or other transport failure leaves the
@@ -228,7 +314,7 @@ export async function proxyIdentityAt(
228
314
  port: number,
229
315
  opts: { hostname?: string; expectedPid?: number } = {},
230
316
  io: LivenessIo = {},
231
- ): Promise<{ pid: number | null; version?: string; role?: string } | null> {
317
+ ): Promise<{ pid: number | null; version?: string; role?: string; packageTreeFenced?: true } | null> {
232
318
  const fetchFn = io.fetchFn ?? directLocalHttpFetch;
233
319
  const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
234
320
  const nowFn = io.nowFn ?? Date.now;
@@ -243,16 +329,33 @@ export async function proxyIdentityAt(
243
329
  if (remainingMs <= 0) return null;
244
330
  const timeoutMs = Math.min(baseTimeoutMs, remainingMs);
245
331
  try {
246
- const res = await fetchFn(`http://${probeHostname(opts.hostname)}:${port}/healthz`, {
332
+ const url = `http://${probeHostname(opts.hostname)}:${port}/healthz`;
333
+ const res = await fetchFn(url, {
247
334
  signal: AbortSignal.timeout(timeoutMs),
248
335
  });
336
+ if (!res.ok && io.acceptPackageTreeFenced && res.status === 503) {
337
+ const fenced = (await res.json().catch(() => null)) as HealthzIdentity | null;
338
+ if (!isPackageTreeFencedHealthz(fenced)) return null;
339
+ const fencedPid = fenced!.pid as number;
340
+ if (opts.expectedPid !== undefined && fencedPid !== opts.expectedPid) return null;
341
+ const attestMs = io.deadlineAt === undefined ? baseTimeoutMs : Math.min(baseTimeoutMs, io.deadlineAt - nowFn());
342
+ if (attestMs <= 0) return null;
343
+ if (!(await attestFencedIdentity(url, port, fencedPid, io, fetchFn, attestMs))) return null;
344
+ const fencedVersion = isHealthzVersion(fenced?.version) ? fenced!.version as string : undefined;
345
+ return {
346
+ pid: fencedPid,
347
+ ...(fencedVersion === undefined ? {} : { version: fencedVersion }),
348
+ packageTreeFenced: true,
349
+ };
350
+ }
249
351
  if (!res.ok) return null;
250
352
  const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
251
353
  if (!isOpencodexHealthz(body)) return null;
252
354
  const pid = typeof body?.pid === "number" ? body.pid : null;
253
355
  if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
254
- // Guarded the same way `pid` is: a non-string version is absent, not coerced.
255
- const version = typeof body?.version === "string" ? body.version : undefined;
356
+ // Whoever holds the port controls this response. Only carry bounded semver text into
357
+ // diagnostics; dropping anything else prevents terminal controls reaching human output.
358
+ const version = isHealthzVersion(body?.version) ? body.version : undefined;
256
359
  // Same guard for the role, for the same reason: absent on a standalone/hub proxy and on
257
360
  // a legacy body, and never coerced from a non-string.
258
361
  const role = typeof body?.role === "string" ? body.role : undefined;
@@ -325,6 +428,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
325
428
  source: "runtime",
326
429
  ...(identity.version === undefined ? {} : { version: identity.version }),
327
430
  ...(identity.role === undefined ? {} : { role: identity.role }),
431
+ ...(identity.packageTreeFenced ? { packageTreeFenced: true as const } : {}),
328
432
  };
329
433
  }
330
434
  }
@@ -349,6 +453,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
349
453
  source: "runtime",
350
454
  ...(identity.version === undefined ? {} : { version: identity.version }),
351
455
  ...(identity.role === undefined ? {} : { role: identity.role }),
456
+ ...(identity.packageTreeFenced ? { packageTreeFenced: true as const } : {}),
352
457
  };
353
458
  }
354
459
  }
@@ -365,6 +470,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
365
470
  source: "config",
366
471
  ...(identity.version === undefined ? {} : { version: identity.version }),
367
472
  ...(identity.role === undefined ? {} : { role: identity.role }),
473
+ ...(identity.packageTreeFenced ? { packageTreeFenced: true as const } : {}),
368
474
  };
369
475
  }
370
476
  return null;
@@ -43,6 +43,7 @@ import {
43
43
  normalizeRequestFailureAttribution,
44
44
  normalizeRequestSpend,
45
45
  readRecentUsageEntries,
46
+ modelIdentityLogFields, recordObservedServedModel,
46
47
  usageForFinalLog,
47
48
  usageStatusForFinalLog,
48
49
  usageTotalTokens,
@@ -168,8 +169,15 @@ export interface RequestLogContext {
168
169
  /** Final-attempt tier summary; attempt rows remain the accounting source of truth. */
169
170
  tierOutcome?: AttemptTierOutcome;
170
171
  resolvedModel?: string;
172
+ /** Upstream served model, retained beside resolvedModel so an upstream reroute stays visible. */
173
+ servedModel?: string;
174
+ /** The exact model id sent upstream; recorded when a route/virtual rewrite makes it differ
175
+ * from the client-facing `model`, so a served-model mismatch can be judged against the wire. */
176
+ wireModel?: string;
171
177
  /** Internal: client-facing response metadata must not replace the physical routed model. */
172
178
  preserveResolvedModelFromRoute?: boolean;
179
+ /** Internal: client-facing selector written into response.model; never an upstream observation. */
180
+ responseModelEcho?: string;
173
181
  usage?: OcxUsage;
174
182
  usageLogInputTokens?: number;
175
183
  /**
@@ -179,6 +187,8 @@ export interface RequestLogContext {
179
187
  * is reserved up front and settlement corrects it.
180
188
  */
181
189
  spendOutputCeilingTokens?: number;
190
+ /** Pre-send input estimate reserved for spend only; unlike usageLogInputTokens it never enters usage. */
191
+ spendInputEstimateTokens?: number;
182
192
  /** Settles this request's durable spend entries from `addFinalRequestLog`. */
183
193
  spendTracker?: RequestSpendSettlement;
184
194
  attempts?: PersistedUsageAttempt[];
@@ -295,6 +305,10 @@ export interface RequestLogEntry {
295
305
  responseServiceTier?: string;
296
306
  tierOutcome?: AttemptTierOutcome;
297
307
  resolvedModel?: string;
308
+ /** Model the upstream actually served (openai-model header or response body). */
309
+ servedModel?: string;
310
+ /** The exact model id sent upstream when it differs from the client-facing `model`. */
311
+ wireModel?: string;
298
312
  status: number;
299
313
  durationMs: number;
300
314
  errorCode?: string;
@@ -444,7 +458,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
444
458
  : {}),
445
459
  ...(entry.responseServiceTier ? { responseServiceTier: entry.responseServiceTier } : {}),
446
460
  ...(entry.tierOutcome ? { tierOutcome: entry.tierOutcome } : {}),
447
- ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
461
+ ...modelIdentityLogFields(entry),
448
462
  status: entry.status,
449
463
  durationMs: entry.durationMs,
450
464
  ...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
@@ -556,11 +570,17 @@ export function addRequestLog(entry: RequestLogEntry) {
556
570
  // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a
557
571
  // sanitization bug because the safe surface is the one you check.
558
572
  const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom);
573
+ const servedModel = modelIdentityLogFields(entry).servedModel;
559
574
  const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
560
- const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && entry.claudeCompatibility === undefined
575
+ const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom
576
+ && servedModel === entry.servedModel && entry.claudeCompatibility === undefined
561
577
  ? entry
562
578
  : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) };
563
579
  if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom;
580
+ if (!servedModel && retained !== entry) {
581
+ delete retained.servedModel;
582
+ if (retained.resolvedModel === entry.servedModel) delete retained.resolvedModel;
583
+ }
564
584
  if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility;
565
585
  else if (retained !== entry) delete retained.claudeCompatibility;
566
586
  entry = retained;
@@ -598,6 +618,8 @@ export function addRequestLog(entry: RequestLogEntry) {
598
618
  : {}),
599
619
  ...(entry.conversationId ? { conversationId: entry.conversationId } : {}),
600
620
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
621
+ ...(entry.servedModel ? { servedModel: entry.servedModel } : {}),
622
+ ...(entry.wireModel ? { wireModel: entry.wireModel } : {}),
601
623
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
602
624
  ...(entry.requestedAlias ? { requestedAlias: entry.requestedAlias } : {}),
603
625
  ...(entry.shadowCallRewrittenFrom
@@ -881,12 +903,7 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk
881
903
  ? (payload as { response?: unknown }).response
882
904
  : payload;
883
905
  if (!source || typeof source !== "object") return;
884
- const model = (source as { model?: unknown }).model;
885
- if (
886
- !logCtx.preserveResolvedModelFromRoute
887
- && typeof model === "string"
888
- && model.trim()
889
- ) logCtx.resolvedModel = model;
906
+ recordObservedServedModel(logCtx, (source as { model?: unknown }).model);
890
907
  const serviceTier = (source as { service_tier?: unknown }).service_tier;
891
908
  if (typeof serviceTier === "string" && serviceTier.trim()) {
892
909
  const sanitized = sanitizeLogMetadataString(serviceTier);
@@ -1519,7 +1536,7 @@ export function addFinalRequestLog(
1519
1536
  ...((attempts?.at(-1)?.tierOutcome ?? logCtx.tierOutcome)
1520
1537
  ? { tierOutcome: attempts?.at(-1)?.tierOutcome ?? { ...logCtx.tierOutcome! } }
1521
1538
  : {}),
1522
- ...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
1539
+ ...modelIdentityLogFields(logCtx),
1523
1540
  status: effectiveStatus,
1524
1541
  durationMs,
1525
1542
  ...(logCtx.firstOutputMs !== undefined ? { firstOutputMs: logCtx.firstOutputMs } : {}),
@@ -38,6 +38,7 @@ export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([
38
38
  "payload",
39
39
  "empty_completion",
40
40
  "effort_downgrade",
41
+ "fast_downgrade",
41
42
  "other",
42
43
  ] as const);
43
44
 
@@ -143,6 +144,10 @@ const CAUSE_METRICS_CLASS = {
143
144
  } as const satisfies Record<RequestFailureCause, RequestMetricsRecoveryClass>;
144
145
 
145
146
  function recoveryClass(kind: AttemptRecoveryKind): RequestMetricsRecoveryClass {
147
+ // Both recoveries answer a rejected parameter, but an operator acts on them differently:
148
+ // an effort downgrade is a model/effort mismatch, a fast downgrade is a missing Anthropic
149
+ // fast-mode entitlement. Keep `effort_downgrade` meaning exactly what it always meant.
150
+ if (kind === "anthropic-fast-downgrade") return "fast_downgrade";
146
151
  return CAUSE_METRICS_CLASS[causeForRecoveryKind(kind)];
147
152
  }
148
153
 
@@ -40,6 +40,7 @@ import {
40
40
  } from "../../oauth/anthropic-routing";
41
41
  import {
42
42
  GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
43
+ hasEligibleGenericOAuthFailoverTarget,
43
44
  isGenericOAuthFailoverEnabled,
44
45
  rotateGenericOAuthAccountOn429,
45
46
  failoverAccountSnapshot,
@@ -431,9 +432,11 @@ export function createAdapterContinuations(
431
432
  route.modelId,
432
433
  )
433
434
  : null;
434
- // Eligible and refused by the shared budget, as opposed to eligible and finding no next
435
- // account: the two produce the same response and need different follow-ups (#5044).
436
- if (!hop.allowed) noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
435
+ // A roster quorum ignores cooldowns, so only attribute a budget refusal when the
436
+ // non-mutating selector confirms that an alternate account could serve this model now.
437
+ if (!hop.allowed && hasEligibleGenericOAuthFailoverTarget(
438
+ route.providerName, transportState.genericFailoverAccountId, Date.now(), route.modelId,
439
+ )) noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
437
440
  if (!nextAccountId) hop.permit?.release();
438
441
  if (nextAccountId) {
439
442
  try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
@@ -62,6 +62,7 @@ import {
62
62
  consoleGoUploadRejectionBody,
63
63
  CONSOLE_GO_UPLOAD_RETRY_DELAY_MS,
64
64
  reasoningEffortRejectionText,
65
+ anthropicFastRefused,
65
66
  } from "./core-opaque-recovery";
66
67
  import { shouldAttemptImageTierRetry } from "../image-retry";
67
68
  import {
@@ -81,6 +82,7 @@ import {
81
82
  } from "../../lib/errors";
82
83
  import { resolveClientRetryAfter } from "../../lib/retry-after";
83
84
  import { cancelBodyOnAbort } from "../../lib/abort";
85
+ import { chargeWorkflowSends } from "../../lib/workflow-budget";
84
86
 
85
87
  /** One responsibility of the Responses request pipeline; state owners are explicit. */
86
88
  export async function prepareAdapterExchange(
@@ -136,6 +138,7 @@ export async function prepareAdapterExchange(
136
138
  | "sendBudgetExhausted"
137
139
  | "reserveCredentialHop"
138
140
  | "pendingHopPermit"
141
+ | "workflowRootId"
139
142
  >,
140
143
  ) {
141
144
  const { options, config, logCtx, req } = requestContext;
@@ -385,6 +388,8 @@ export async function prepareAdapterExchange(
385
388
  // below for the same reason the two guards above do: a guard declared inside it is reset by
386
389
  // every `continue recovery`, which would let one turn walk the whole ladder down.
387
390
  const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false };
391
+ // At most one Anthropic fast-mode downgrade per request, for the same reason.
392
+ const anthropicFastDowngradeGuard: { attempted: boolean } = { attempted: false };
388
393
  /**
389
394
  * Rebuild the request from the current parsed input (and any image-tier bias) and refetch
390
395
  * it once, tagging the attempt with the given recovery kind. Rebuilds are deterministic
@@ -400,6 +405,15 @@ export async function prepareAdapterExchange(
400
405
  */
401
406
  onDispatch?: () => void,
402
407
  ): Promise<Response | { failed: Response }> => {
408
+ // The repair permit books the request ledger, but only the transient helper reports
409
+ // that send to the root workflow. The other two dispatch paths confirm it here, at
410
+ // their physical-send boundary, without booking the request ledger again.
411
+ let fastDowngradeWorkflowCharged = false;
412
+ const chargeFastDowngradeWorkflowSend = (): void => {
413
+ if (recovery !== "anthropic-fast-downgrade" || fastDowngradeWorkflowCharged) return;
414
+ fastDowngradeWorkflowCharged = true;
415
+ chargeWorkflowSends(sendBudgetState.workflowRootId, 1);
416
+ };
403
417
  let retryRequest: AdapterRequest;
404
418
  if (transportState.sameTargetRequest !== undefined && transportState.sameTargetParsed === parsed && transportState.sameTargetToken === transportState.transportToken) {
405
419
  // Same target (key/adapter/parsed/tier unchanged): replay the exact cached request.
@@ -450,7 +464,10 @@ export async function prepareAdapterExchange(
450
464
  abortSignal: upstream.signal,
451
465
  timeoutMs: connectMs,
452
466
  sendBudget: adapterDispatchBudget,
453
- onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
467
+ onPhysicalSend: send => {
468
+ noteAdapterPhysicalSend(retryEstimate, send);
469
+ chargeFastDowngradeWorkflowSend();
470
+ },
454
471
  onRecoveryWithheld: noteAdapterRecoveryWithheld,
455
472
  stream: parsed.stream,
456
473
  executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
@@ -491,6 +508,7 @@ export async function prepareAdapterExchange(
491
508
  // Same boundary on the helper path: the thunk is what reaches the wire, and it
492
509
  // can be refused above before it does. use() past the first attempt is a no-op.
493
510
  onDispatch?.();
511
+ if (!refetchTransientPolicy) chargeFastDowngradeWorkflowSend();
494
512
  return fetchWithHeaderTimeout(retryRequest.url,
495
513
  applyUpstreamRecoveryInit({
496
514
  method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
@@ -638,6 +656,52 @@ export async function prepareAdapterExchange(
638
656
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
639
657
  }
640
658
 
659
+ // Anthropic fast mode refused (no usage credits, organization not enabled, model outside
660
+ // the lane, fast pool empty): resend once at standard speed, as Claude Code does. This
661
+ // sits before every 429 arm because the refusal says nothing about the account's standard
662
+ // lane; waiting, cooling, or rotating on it would punish a healthy credential. The
663
+ // resend is reserved and confirmed exactly like the generic OAuth hop below, and the
664
+ // decision lives on this request, so every later rebuild of it stays standard.
665
+ if (await anthropicFastRefused(
666
+ upstreamResponse,
667
+ transportState.sameTargetRequest,
668
+ transportState.activeAdapter.name,
669
+ anthropicFastDowngradeGuard.attempted,
670
+ upstream.signal,
671
+ )) {
672
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
673
+ const hop = reserveCredentialHop(
674
+ "repair",
675
+ `${route.providerName}|${route.modelId}|anthropic-fast-downgrade`,
676
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
677
+ );
678
+ if (hop.allowed) {
679
+ anthropicFastDowngradeGuard.attempted = true;
680
+ parsed.options.tierDecision = { kind: "drop" };
681
+ parsed.options.serviceTier = undefined;
682
+ if (parsed.options.tierObservation) {
683
+ parsed.options.tierObservation = { ...parsed.options.tierObservation, upstreamDeclinedFast: true };
684
+ }
685
+ invalidateSameTargetRequest();
686
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
687
+ sendBudgetState.pendingHopPermit = hop.permit;
688
+ let result: Response | { failed: Response };
689
+ try {
690
+ result = await rebuildAndRefetch("anthropic-fast-downgrade", () => {
691
+ if (!adapterOwnsDispatch) hop.permit?.use();
692
+ });
693
+ } finally {
694
+ sendBudgetState.pendingHopPermit = undefined;
695
+ }
696
+ if ("failed" in result) {
697
+ hop.permit?.release();
698
+ return result.failed;
699
+ }
700
+ upstreamResponse = result;
701
+ continue recovery;
702
+ }
703
+ }
704
+
641
705
  // Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
642
706
  // 429 itself (it retries 5xx only), and single-key pools cannot use the failover below,
643
707
  // so wait (Retry-After or the fixed interval) and replay the IDENTICAL request on the
@@ -51,6 +51,7 @@ import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSide
51
51
  import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
52
52
  import {
53
53
  applyCodexAuthContextToProvider,
54
+ callerCodexWorkspaceAccountId,
54
55
  createCodexReserveDispatchGuard,
55
56
  unwrapUpstreamRetryEvidenceError,
56
57
  CodexMainProfileDrainingError,
@@ -186,6 +187,7 @@ import {
186
187
  handleResponses,
187
188
  preAuthUpstreamHostCircuitKey,
188
189
  poolCredentialRefreshIncompleteResponse,
190
+ shouldRetryCodexScopedQuotaOnAlternate,
189
191
  upstreamHostCircuitOpenResponse,
190
192
  usesCodexForwardPoolAuth,
191
193
  } from "./core";
@@ -709,6 +711,7 @@ export async function handleResponsesCompact(
709
711
  captureOpenAiVirtualWirePolicy(route, virtual);
710
712
  logCtx.model = virtual.selectedModelId;
711
713
  logCtx.resolvedModel = virtual.wireModelId;
714
+ logCtx.wireModel = virtual.wireModelId;
712
715
  } else {
713
716
  logCtx.resolvedModel = route.modelId;
714
717
  }
@@ -1211,9 +1214,36 @@ export async function handleResponsesCompact(
1211
1214
  if (alternate && req.signal.aborted) {
1212
1215
  releaseCodexAuthContextProbeLease(alternate.authCtx);
1213
1216
  recordCompactPoolOutcome(outcomeCtx, 499);
1217
+ void upstream.body?.cancel(req.signal.reason).catch(() => undefined);
1214
1218
  return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
1215
1219
  }
1216
- if (alternate) {
1220
+ // The same scope binding the regular path applies: an organization-scoped
1221
+ // exhaustion refuses every credential in that workspace, so a proven
1222
+ // same-workspace alternate pays a cold prompt prefix for no new capacity.
1223
+ // Suppression is not silence — the buffered recorder below still attributes
1224
+ // the 429/402 to the account that produced it.
1225
+ const sharedWorkspaceScope = alternate != null
1226
+ && !await shouldRetryCodexScopedQuotaOnAlternate(
1227
+ upstream,
1228
+ authCtx.chatgptAccountId,
1229
+ alternate.authCtx.kind === "pool" || alternate.authCtx.kind === "main-pool"
1230
+ ? alternate.authCtx.chatgptAccountId
1231
+ : callerCodexWorkspaceAccountId(req.headers),
1232
+ req.signal,
1233
+ );
1234
+ // The scope check reads the rejection body asynchronously — the same window the
1235
+ // comment above covers. Re-check before the branch below records A, cancels its
1236
+ // body, and sends B for a caller that is gone.
1237
+ if (alternate && req.signal.aborted) {
1238
+ releaseCodexAuthContextProbeLease(alternate.authCtx);
1239
+ recordCompactPoolOutcome(outcomeCtx, 499);
1240
+ void upstream.body?.cancel(req.signal.reason).catch(() => undefined);
1241
+ return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
1242
+ }
1243
+ if (alternate && sharedWorkspaceScope) {
1244
+ releaseCodexAuthContextProbeLease(alternate.authCtx);
1245
+ }
1246
+ if (alternate && !sharedWorkspaceScope) {
1217
1247
  // Same order the regular path uses (core.ts:349-357): a 429/402 carries the
1218
1248
  // quota snapshot that produced it, so refresh A's cache before recording its
1219
1249
  // rejection. Skipping this leaves quota-strategy routing and the dashboard
@@ -3,6 +3,8 @@ import { isDeclaredReasoningEffort } from "../../reasoning-effort";
3
3
  import { COMPACTION_TRIGGERS } from "../../config/schema/compaction-triggers";
4
4
  import { routeConcreteModel, type RouteResult } from "../../router";
5
5
  import { resolveComboId } from "../../combos/identifiers";
6
+ import { resolvePolicyProfileId } from "../../routing/profile";
7
+ import { parseSyntheticRowId } from "../fast-row";
6
8
  import { recallComboForLane } from "./combo-session-recall";
7
9
  import { sessionLaneIdFromRequest } from "../request-log-conversation";
8
10
 
@@ -83,7 +85,7 @@ export function applyCompactionRoutingOverride(
83
85
  if (trigger === undefined) return null;
84
86
 
85
87
  const sourceModel = raw.model;
86
- const sourceCombo = recallComboForLane(config, sessionLaneIdFromRequest(headers), sourceModel);
88
+ const sourceCombo = recallComboForLane(config, sessionLaneIdFromRequest(headers), sourceSelectorOf(config, sourceModel));
87
89
  const targetCombo = resolveComboId(config, override.model.trim()) ?? undefined;
88
90
  raw.model = override.model.trim();
89
91
  if (override.reasoningEffort !== undefined) {
@@ -92,19 +94,42 @@ export function applyCompactionRoutingOverride(
92
94
  return { sourceModel, ...(sourceCombo ? { sourceCombo } : {}), ...(targetCombo ? { targetCombo } : {}) };
93
95
  }
94
96
 
97
+ /**
98
+ * The selector a synthetic-row grammar actually routed on. `--fast`/`--effort` suffixes are
99
+ * decoration applied at ingress; identity checks must see the base id or a decorated
100
+ * virtual selector (`alias--fast`) slips past them.
101
+ */
102
+ function sourceSelectorOf(config: OcxConfig, sourceModel: string): string {
103
+ const { fastRow, effortRow } = parseSyntheticRowId(sourceModel, config);
104
+ return fastRow?.baseId ?? effortRow?.baseId ?? sourceModel;
105
+ }
106
+
95
107
  /** Same provider identity keeps caller auth and may use native compact; its ciphertext replays only there. */
96
108
  export function compactionRoutingKeepsProviderIdentity(
97
109
  config: OcxConfig,
98
110
  override: CompactionRoutingOverride,
99
111
  route: RouteResult,
100
112
  ): boolean {
101
- if (route.combo || override.sourceCombo || override.targetCombo || resolveComboId(config, override.sourceModel)) return false;
113
+ // `sourceModel` is the selector as the client sent it, so a synthetic `--fast` or
114
+ // effort suffix can still be attached. The base id is what the conversation routed on,
115
+ // and only the base can match the combo/policy guards below.
116
+ const sourceSelector = sourceSelectorOf(config, override.sourceModel);
117
+ if (route.combo || override.sourceCombo || override.targetCombo || resolveComboId(config, sourceSelector)) return false;
118
+ // A policy selector does not identify one stable serving backend: its route depends on
119
+ // request evidence and live candidate state that this post-rewrite check no longer has.
120
+ // Treat it as crossing identity rather than reconstructing it through concrete routing,
121
+ // which deliberately bypasses policy evaluation and may fall through to defaultProvider.
122
+ if (resolvePolicyProfileId(config, sourceSelector) !== null) return false;
102
123
  let source: RouteResult;
103
124
  try {
104
- source = routeConcreteModel(config, override.sourceModel);
125
+ source = routeConcreteModel(config, sourceSelector);
105
126
  } catch {
106
127
  return false;
107
128
  }
129
+ // The default-provider branch is where every unrecognized selector lands — including a
130
+ // policy/combo alias that was renamed or deleted since the conversation began. Such a
131
+ // selector cannot prove which backend served it, so it can never match an identity.
132
+ if (source.routeReason === "default-provider") return false;
108
133
  return source.providerName === route.providerName
109
134
  && source.codexAccountMode === route.codexAccountMode
110
135
  && source.codexAccountNamespace === route.codexAccountNamespace;