@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -127,6 +127,7 @@ export interface ProviderRegistryEntry {
127
127
  adapter: string;
128
128
  baseUrl: string;
129
129
  apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
130
+ alias?: string;
130
131
  authKind: ProviderAuthKind;
131
132
  codexAccountMode?: CodexAccountMode;
132
133
  /** OAuth preset may explicitly honor a persisted API-key billing mode. */
@@ -349,6 +350,38 @@ export type ProviderConfigSeed = Pick<
349
350
  // always on, per the official models overview and pricing page (platform.claude.com).
350
351
  const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
351
352
  const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
353
+ // Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x
354
+ // through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a
355
+ // larger request never over-allocates; it only stops the 8192 truncation.
356
+ const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
357
+ /**
358
+ * The effort rungs opencodex exposes for native Anthropic models. Without this the
359
+ * providers advertised no ladder at all, so every client that keys its effort control off
360
+ * `reasoningEfforts` — Aside and the rest of the Pi-shaped exports — wrote these models
361
+ * with no control, while the SAME Claude models routed through `cursor` or
362
+ * `google-antigravity` had one.
363
+ *
364
+ * This is an opencodex ladder, not a claim that each model takes `output_config.effort`.
365
+ * The adapter serves two wire shapes (src/adapters/anthropic.ts): adaptive families
366
+ * (fable, sonnet >= 5, opus >= 4.7) send the effort directly, while opus 4.6, sonnet 4.6
367
+ * and haiku 4.5 take the legacy path where `reasoningBudget` TRANSLATES each rung into
368
+ * `thinking.budget_tokens`. Anthropic documents `low|medium|high|max` for the 4.6 models
369
+ * and no effort parameter at all for haiku 4.5; the budget translation is what makes five
370
+ * rungs meaningful there, and it clamps below `max_tokens` so none of them 400.
371
+ *
372
+ * Deliberately excluded, each because advertising it would offer a control that does not
373
+ * do what it says:
374
+ * - `minimal`: `adaptiveEffort` rewrites it to `low` (the adaptive wire 400s on it), so
375
+ * it is not a distinct setting.
376
+ * - `none`: only sonnet >= 5 accepts an explicit thinking disable
377
+ * (`EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS`); Fable rejects one outright.
378
+ * - `ultra`: not an Anthropic concept, and it is degraded to `max` at the request
379
+ * boundary anyway (src/responses/parser.ts).
380
+ */
381
+ const ANTHROPIC_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
382
+ const ANTHROPIC_MODEL_REASONING_EFFORTS: Record<string, string[]> = Object.fromEntries(
383
+ ANTHROPIC_MODELS.map(id => [id, [...ANTHROPIC_REASONING_EFFORTS]]),
384
+ );
352
385
 
353
386
  // 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's
354
387
  // devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and
@@ -600,7 +633,7 @@ const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
600
633
  * `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images and are deliberately
601
634
  * absent. Adding them would silently replace a working image with a caption,
602
635
  * which is worse than the loud 400 this list exists to prevent — see the negative
603
- * assertion in tests/provider-registry-parity.test.ts.
636
+ * assertion in tests/providers/provider-registry-parity.test.ts.
604
637
  *
605
638
  * Zen's roster is discovered live while this list is static, so it is a dated
606
639
  * exception list, not a capability model. Re-probe before extending it.
@@ -656,7 +689,7 @@ const DEEPSEEK_FLASH_REASONING_MAP: Record<string, string> = {
656
689
  /**
657
690
  * Flash-versus-Pro classification for DeepSeek V4 model ids, including prefixed
658
691
  * (`deepseek/deepseek-v4-pro`) and suffixed (`deepseek-v4-flash-free`) forms.
659
- * `tests/provider-registry-parity.test.ts` enumerates every id the registry
692
+ * `tests/providers/provider-registry-parity.test.ts` enumerates every id the registry
660
693
  * actually passes here, so a future id this substring test would misread cannot
661
694
  * land silently.
662
695
  */
@@ -1222,20 +1255,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1222
1255
  // than the seeded ones do.
1223
1256
  supportsVerbosity: false,
1224
1257
  defaultModel: "grok-4.5",
1225
- // Keep 4.6/4.5 Responses callers on the compatibility Chat wire until xAI can replay
1226
- // opaque reasoning continuation and compaction state across later turns. Multi-agent has
1227
- // no Chat wire, so Responses callers use its only working wire under both auth modes.
1258
+ // Grok 4.6/4.5 subscription Responses callers use the native wire with the existing
1259
+ // namespace/web-search/replay normalization. Chat remains an explicit modelAdapters
1260
+ // opt-in. Multi-agent has no Chat wire and uses Responses under both auth modes.
1228
1261
  // Caller-owned service tiers stay off the unclassified OAuth subscription route; key-auth
1229
1262
  // Fast remains proxy-owned and is still selected through keyAuthServiceTier above.
1230
1263
  modelWireDefaults: {
1231
1264
  "grok-4.6": {
1232
- wire: "openai-chat",
1265
+ wire: "openai-responses",
1233
1266
  inbound: ["responses"],
1234
1267
  authModes: ["oauth"],
1235
1268
  forwardCallerServiceTier: false,
1236
1269
  },
1237
1270
  "grok-4.5": {
1238
- wire: "openai-chat",
1271
+ wire: "openai-responses",
1239
1272
  inbound: ["responses"],
1240
1273
  authModes: ["oauth"],
1241
1274
  forwardCallerServiceTier: false,
@@ -1340,6 +1373,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1340
1373
  note: "Log in with your Claude account",
1341
1374
  models: [...ANTHROPIC_MODELS],
1342
1375
  modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
1376
+ modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS },
1377
+ // Codex omits max_output_tokens; without a provider budget the Anthropic adapter
1378
+ // falls back to 8192, which truncates long answers with stop_reason=max_tokens.
1379
+ defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
1343
1380
  defaultModel: "claude-sonnet-5",
1344
1381
  },
1345
1382
  {
@@ -1356,6 +1393,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1356
1393
  models: [...ANTHROPIC_MODELS],
1357
1394
  liveModels: true,
1358
1395
  modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
1396
+ modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS },
1397
+ defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
1359
1398
  defaultModel: "claude-sonnet-5",
1360
1399
  },
1361
1400
  {
@@ -1458,12 +1497,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1458
1497
  featured: true,
1459
1498
  dashboardUrl: "https://platform.openai.com/api-keys",
1460
1499
  defaultModel: "gpt-5.5",
1461
- models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS],
1500
+ models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"],
1462
1501
  liveModels: true,
1463
- modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, ...OPENAI_DAYBREAK_CONTEXT_WINDOWS },
1464
- modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, ...OPENAI_DAYBREAK_MAX_INPUT_TOKENS },
1502
+ modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, ...OPENAI_DAYBREAK_CONTEXT_WINDOWS, "gpt-6-astra": 1_050_000 },
1503
+ modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, ...OPENAI_DAYBREAK_MAX_INPUT_TOKENS, "gpt-6-astra": 922_000 },
1504
+ modelMaxOutputTokens: { "gpt-6-astra": 128_000 },
1465
1505
  modelInputModalities: Object.fromEntries(
1466
- ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS]
1506
+ ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"]
1467
1507
  .map(id => [id, ["text", "image"]]),
1468
1508
  ),
1469
1509
  modelReasoningEfforts: {
@@ -1471,6 +1511,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1471
1511
  [...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_REASONING_EFFORTS]),
1472
1512
  ),
1473
1513
  ...OPENAI_DAYBREAK_REASONING_EFFORTS,
1514
+ "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
1474
1515
  },
1475
1516
  virtualModels: OPENAI_API_GPT56_VIRTUAL_MODELS,
1476
1517
  },
@@ -1503,7 +1544,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1503
1544
  modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])),
1504
1545
  // text+image only. Meta also documents video, audio (degraded on 1.3), and PDF, but
1505
1546
  // the catalog modality enum is text/image and over-advertising poisons the exported
1506
- // client config (see tests/catalog-input-modality-enum.test.ts).
1547
+ // client config (see tests/codex-integration/catalog-input-modality-enum.test.ts).
1507
1548
  modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])),
1508
1549
  modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])),
1509
1550
  modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])),
@@ -1519,7 +1560,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1519
1560
  - 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key.
1520
1561
  - 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token.
1521
1562
  - 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing.
1522
- - 선택한 방식: an import-only, macOS-only OAuth provider that reads the existing credential, validates it once, and never spawns or reimplements anything.
1563
+ - 선택한 방식: an OAuth provider that imports the existing credential on macOS and accepts a pasted key elsewhere, validates either once, and never spawns or reimplements anything.
1523
1564
  - 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish.
1524
1565
  - 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported.
1525
1566
  */
@@ -1540,7 +1581,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1540
1581
  modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])),
1541
1582
  modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])),
1542
1583
  modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])),
1543
- note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).",
1584
+ note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI and the Linux credential storage has not been measured, so on those platforms OpenCodex asks you to paste the Muse Code API key from https://dev.meta.ai instead of importing one; a pasted key faces the same format check and live validation as an imported one. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The key, imported or pasted, is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).",
1544
1585
  },
1545
1586
  {
1546
1587
  id: "umans",
@@ -1576,7 +1617,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1576
1617
  // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON.
1577
1618
  openaiChatEofTolerance: true,
1578
1619
  /* [Decision Log]
1579
- - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, and Muse Spark 1.2 Contributor (#2617).
1620
+ - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617).
1580
1621
  - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative.
1581
1622
  - 검토한 주요 대안: Change the whole provider to Responses; infer the wire from model-family names; add one registry-only exact-model default.
1582
1623
  - 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule.
@@ -1585,6 +1626,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1585
1626
  */
1586
1627
  modelWireDefaults: {
1587
1628
  "gpt-5.6-luna": "openai-responses",
1629
+ "grok-4.6": "openai-responses",
1588
1630
  "muse-spark-1.3-contributor": "openai-responses",
1589
1631
  "muse-spark-1.2-contributor": "openai-responses",
1590
1632
  },
@@ -1614,6 +1656,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1614
1656
  },
1615
1657
  modelReasoningEfforts: {
1616
1658
  "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS,
1659
+ "grok-4.6": ["low", "medium", "high", "xhigh"],
1617
1660
  "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
1618
1661
  "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
1619
1662
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
@@ -1625,7 +1668,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1625
1668
  ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
1626
1669
  ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
1627
1670
  },
1628
- modelDefaultReasoningEfforts: { "kimi-k3": "max" },
1671
+ modelDefaultReasoningEfforts: { "grok-4.6": "high", "kimi-k3": "max" },
1629
1672
  // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
1630
1673
  // the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
1631
1674
  modelReasoningEffortMap: {
@@ -1858,7 +1901,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1858
1901
  // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
1859
1902
  // evidence from ai.google.dev does not establish Vertex publisher availability.
1860
1903
  { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
1861
- { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
1904
+ { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
1862
1905
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
1863
1906
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
1864
1907
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
@@ -1,15 +1,41 @@
1
- import type { OcxProviderConfig } from "../types";
1
+ import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxConfig, type OcxProviderConfig } from "../types";
2
+ import { providerModelWireDefault } from "./registry";
2
3
 
3
4
  export const XAI_RESPONSES_OPT_IN_MODELS = ["grok-4.6", "grok-4.5"] as const;
5
+ export const XAI_RESPONSES_DEFAULT_VERSION = 1;
4
6
 
5
7
  export type XaiResponsesOptInState = boolean | "mixed";
6
8
 
7
- /** Derived dashboard/API state for the two modelAdapters entries owned by the xAI opt-in. */
9
+ /** Effective Responses-inbound wire; the legacy API field name remains compatible. */
8
10
  export function xaiResponsesOptInState(provider: OcxProviderConfig): XaiResponsesOptInState {
9
- const enabled = XAI_RESPONSES_OPT_IN_MODELS.map(
10
- model => provider.modelAdapters?.[model] === "openai-responses",
11
- );
11
+ const enabled = XAI_RESPONSES_OPT_IN_MODELS.map(model => {
12
+ const configured = provider.modelAdapters?.[model];
13
+ const wire = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)
14
+ ? configured
15
+ : providerModelWireDefault("xai", provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses")
16
+ ?? provider.adapter;
17
+ return wire === "openai-responses";
18
+ });
12
19
  if (enabled.every(Boolean)) return true;
13
20
  if (enabled.some(Boolean)) return "mixed";
14
21
  return false;
15
22
  }
23
+
24
+ /** Upgrade old Chat choices once; a later explicit Chat opt-in must survive restart. */
25
+ export function migrateXaiResponsesDefault(config: OcxConfig): boolean {
26
+ const provider = config.providers.xai;
27
+ if (!provider || (provider.xaiResponsesDefaultVersion ?? 0) >= XAI_RESPONSES_DEFAULT_VERSION) return false;
28
+ if (!XAI_RESPONSES_OPT_IN_MODELS.every(model =>
29
+ providerModelWireDefault("xai", provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses") === "openai-responses")) {
30
+ return false;
31
+ }
32
+ const modelAdapters = { ...provider.modelAdapters };
33
+ for (const model of XAI_RESPONSES_OPT_IN_MODELS) {
34
+ if (modelAdapters[model] === "openai-chat") delete modelAdapters[model];
35
+ }
36
+ const next = { ...provider, xaiResponsesDefaultVersion: XAI_RESPONSES_DEFAULT_VERSION };
37
+ if (Object.keys(modelAdapters).length) next.modelAdapters = modelAdapters;
38
+ else delete next.modelAdapters;
39
+ config.providers = { ...config.providers, xai: next };
40
+ return true;
41
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Activation: install the delivery sink so a detected reset actually reaches the operator.
3
+ *
4
+ * Separate from the poller because the two are independent. `pollSeconds: 0` is a supported
5
+ * configuration — observe live quota refreshes, never probe on a timer — and in that mode the
6
+ * sink must still be installed or detection would run its bookkeeping and deliver nowhere.
7
+ *
8
+ * The sink is what the wp3 seams gate on: with none installed, observeQuotaSnapshot returns
9
+ * immediately and no baseline is even stored. So this module is the single switch that turns the
10
+ * whole subsystem from inert to live, which is why it re-reads config on every event rather than
11
+ * capturing it once — an operator who changes `kinds` should not have to restart.
12
+ */
13
+
14
+ import type { QuotaResetEvent } from "./reset-detector";
15
+
16
+ let activated = false;
17
+
18
+ /**
19
+ * Install the sink when config asks for it, and remove it when config no longer does.
20
+ *
21
+ * Idempotent and cheap to call repeatedly: the enable check is the mtime-cached resolver, not a
22
+ * config parse. Returns whether a sink is now installed, for the caller's own reporting.
23
+ */
24
+ export async function syncQuotaResetActivation(): Promise<boolean> {
25
+ const [{ isQuotaResetNotificationEnabled }, observer] = await Promise.all([
26
+ import("./reset-notify-config"),
27
+ import("./reset-observer"),
28
+ ]);
29
+
30
+ if (!isQuotaResetNotificationEnabled()) {
31
+ // Deliberately clears a previously installed sink. Disabling in config must actually stop
32
+ // delivery on the next check, not merely stop new observations.
33
+ if (activated) {
34
+ observer.setQuotaResetSink(null);
35
+ activated = false;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ if (activated) return true;
41
+ observer.setQuotaResetSink(dispatch);
42
+ activated = true;
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Hand one event to the sinks.
48
+ *
49
+ * Synchronous by signature (the observer contract) and fire-and-forget in body: delivery must
50
+ * never delay the quota write that triggered it. The observer has already claimed the
51
+ * idempotence key by the time this runs, so a failed delivery is not retried — see reset-sinks.
52
+ *
53
+ * Config is resolved HERE, per event, so a changed `kinds` list or webhook URL takes effect
54
+ * without a restart.
55
+ */
56
+ function dispatch(event: QuotaResetEvent): void {
57
+ void (async () => {
58
+ try {
59
+ const [{ currentQuotaResetNotify }, { deliverQuotaResetEvent }] = await Promise.all([
60
+ import("./reset-notify-config"),
61
+ import("./reset-sinks"),
62
+ ]);
63
+ const config = currentQuotaResetNotify();
64
+ if (!config.enabled) return;
65
+ await deliverQuotaResetEvent(event, config);
66
+ } catch {
67
+ // Best-effort by contract. deliverQuotaResetEvent does not reject, so reaching here means
68
+ // the import itself failed, which the next event will retry.
69
+ }
70
+ })();
71
+ }
72
+
73
+ /** Test-only: forget activation state so a suite can re-activate against fresh config. */
74
+ export function resetQuotaResetActivationForTests(): void {
75
+ activated = false;
76
+ }
77
+
78
+ /** Test-only: whether this module currently believes a sink is installed. */
79
+ export function isQuotaResetActivatedForTests(): boolean {
80
+ return activated;
81
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Pure quota-reset detection: two consecutive observations of one usage window in, at most
3
+ * one reset event out.
4
+ *
5
+ * Nothing here reads a clock, a config, or the disk. `now` is a parameter so a test can
6
+ * place a deadline in the past without waiting for it, and so the same snapshot pair always
7
+ * yields the same answer.
8
+ *
9
+ * Design and the seven false-positive traps this guards against:
10
+ * devlog/_plan/260828_quota_reset_detection/000_plan.md
11
+ */
12
+
13
+ /** One observed usage window, normalized away from provider-specific field names. */
14
+ export type QuotaWindowObservation = {
15
+ /** Window identity: "5h", "weekly", "monthly", or "custom:<label>". */
16
+ readonly window: string;
17
+ /** 0-100 used percent. Absent when upstream stopped reporting this window. */
18
+ readonly percent?: number;
19
+ /** Epoch ms. Absent when upstream declares no clock, or declared a sentinel. */
20
+ readonly resetAt?: number;
21
+ /**
22
+ * Window length in seconds, when upstream states it.
23
+ *
24
+ * Used only to bound natural decay in a rolling window (see the surprise branch). Absent
25
+ * for providers that never declare a length; the label table then supplies a conservative
26
+ * default.
27
+ */
28
+ readonly windowSeconds?: number;
29
+ /**
30
+ * When this observation was taken, stamped by the observer as it stores the baseline.
31
+ *
32
+ * Needed because a rolling window's percent decays with WALL TIME, so distinguishing decay
33
+ * from a reset requires knowing how much time separates the two observations. Absent in
34
+ * baselines written before this field existed, and the decay bound is then skipped rather
35
+ * than guessed.
36
+ */
37
+ readonly observedAt?: number;
38
+ };
39
+
40
+ export type QuotaResetKind = "scheduled" | "surprise";
41
+
42
+ export type QuotaResetEvent = {
43
+ readonly kind: QuotaResetKind;
44
+ /** "codex" or a provider name. Never an account identifier. */
45
+ readonly scope: string;
46
+ /** Non-identifying account discriminator; see quotaAccountTag. */
47
+ readonly accountTag: string;
48
+ readonly window: string;
49
+ readonly percentBefore?: number;
50
+ readonly percentAfter?: number;
51
+ readonly previousResetAt?: number;
52
+ readonly resetAt?: number;
53
+ /**
54
+ * When WE noticed, not when the reset happened.
55
+ *
56
+ * Observation cadence is bounded by the 5-minute provider cache TTL and the 10-minute
57
+ * per-account TTL, so the reset instant can only ever be bracketed between two
58
+ * observations. Naming this field `detectedAt` keeps that limitation visible to every
59
+ * consumer instead of implying a precision we do not have.
60
+ */
61
+ readonly detectedAt: number;
62
+ /** Idempotence key: scope|accountTag|window|resetAt. */
63
+ readonly key: string;
64
+ };
65
+
66
+ /**
67
+ * A drop smaller than this is rounding noise or a same-window correction, not a reset.
68
+ *
69
+ * Upstream percents are integers and a real window rollover drops by tens of points, so
70
+ * nothing genuine sits under this floor. It applies only to the surprise branch: a
71
+ * scheduled rollover is proven by its own expired deadline and needs no magnitude test.
72
+ */
73
+ export const MIN_SURPRISE_DROP_PERCENT = 5;
74
+
75
+ /**
76
+ * Account discriminator: stable for this install, unlinkable outside it.
77
+ *
78
+ * Events must distinguish accounts — a provider report is keyed by provider only, so an
79
+ * account switch would otherwise inherit the previous account's history — while carrying no
80
+ * account identity, because the payload crosses a webhook boundary to a third party.
81
+ *
82
+ * The salt is what makes the second half true. An unsalted `Bun.hash` of an email is
83
+ * brute-forceable in tens of guesses against a small, highly guessable input space, which
84
+ * would let a webhook recipient confirm-or-deny any guessed account. Salted, the tag is
85
+ * meaningless to anyone without the install salt, and still stable across restarts because
86
+ * the salt is persisted — which is what the durable idempotence key depends on.
87
+ *
88
+ * Not a cryptographic commitment: it defeats an offline dictionary attack by a payload
89
+ * recipient, which is the threat the privacy constraint names.
90
+ */
91
+ export function quotaAccountTag(accountKey: string, salt: string): string {
92
+ return Bun.hash(`${salt}\u0000${accountKey}`).toString(36).slice(0, 8).padStart(8, "0");
93
+ }
94
+
95
+ export function quotaResetKey(input: {
96
+ readonly scope: string;
97
+ readonly accountTag: string;
98
+ readonly window: string;
99
+ readonly resetAt?: number;
100
+ readonly previousResetAt?: number;
101
+ }): string {
102
+ // Prefer the NEW deadline: every later observation of the same post-reset window computes
103
+ // the same key, which is what makes repeated detection idempotent.
104
+ //
105
+ // Fall back to the deadline that just expired when upstream reports no new one. A bare
106
+ // "none" discriminator would collapse every clockless reset of one window onto a single
107
+ // key, so the first claim would permanently suppress all later ones.
108
+ const discriminator = input.resetAt ?? input.previousResetAt ?? "none";
109
+ return [input.scope, input.accountTag, input.window, discriminator].join("|");
110
+ }
111
+
112
+ /**
113
+ * Percent guard applied at this boundary.
114
+ *
115
+ * Both upstream normalizers clamp to 0-100, but a value outside that range means the payload
116
+ * bypassed them, and admitting a negative would manufacture an enormous apparent drop. Same
117
+ * philosophy as the resetAt guard below: re-check rather than trust the caller.
118
+ */
119
+ function finitePercent(value: number | undefined): number | undefined {
120
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
121
+ return value >= 0 && value <= 100 ? value : undefined;
122
+ }
123
+
124
+ /**
125
+ * Epoch-ms guard applied at this boundary on purpose.
126
+ *
127
+ * The two callers normalize differently — src/providers/quota.ts:279 treats <= 0 as a
128
+ * sentinel and scales seconds to ms, while src/codex/quota.ts:192 admits 0 and does not
129
+ * scale — so the detector cannot trust either and re-checks here.
130
+ */
131
+ function finiteResetAt(value: number | undefined): number | undefined {
132
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
133
+ }
134
+
135
+ /**
136
+ * Decide whether one window transition is a reset.
137
+ *
138
+ * Returns null for every ambiguous case. The bias is deliberate: a missed notification is
139
+ * an inconvenience, while a false one trains the operator to ignore the channel.
140
+ */
141
+ export function detectQuotaReset(input: {
142
+ readonly scope: string;
143
+ readonly accountTag: string;
144
+ readonly previous: QuotaWindowObservation | undefined;
145
+ readonly next: QuotaWindowObservation;
146
+ readonly now: number;
147
+ }): QuotaResetEvent | null {
148
+ const { scope, accountTag, previous, next, now } = input;
149
+
150
+ // No baseline, no transition. This one line is what stops a cold start (writers do not
151
+ // hydrate from disk), a reauth row clear, a reconciliation delete, and an account switch
152
+ // from each manufacturing an event out of thin air.
153
+ if (!previous) return null;
154
+ if (previous.window !== next.window) return null;
155
+
156
+ const percentBefore = finitePercent(previous.percent);
157
+ const percentAfter = finitePercent(next.percent);
158
+ const previousResetAt = finiteResetAt(previous.resetAt);
159
+ const resetAt = finiteResetAt(next.resetAt);
160
+
161
+ const build = (kind: QuotaResetKind): QuotaResetEvent => ({
162
+ kind,
163
+ scope,
164
+ accountTag,
165
+ window: next.window,
166
+ ...(percentBefore !== undefined ? { percentBefore } : {}),
167
+ ...(percentAfter !== undefined ? { percentAfter } : {}),
168
+ ...(previousResetAt !== undefined ? { previousResetAt } : {}),
169
+ ...(resetAt !== undefined ? { resetAt } : {}),
170
+ detectedAt: now,
171
+ key: quotaResetKey({
172
+ scope,
173
+ accountTag,
174
+ window: next.window,
175
+ ...(resetAt !== undefined ? { resetAt } : {}),
176
+ ...(previousResetAt !== undefined ? { previousResetAt } : {}),
177
+ }),
178
+ });
179
+
180
+ // A window whose percent vanished says nothing about a reset: upstream simply stopped
181
+ // reporting it. Treating absence as 0% would fire on every degraded payload.
182
+ if (percentAfter === undefined) return null;
183
+
184
+ const deadlinePassed = previousResetAt !== undefined && now >= previousResetAt;
185
+
186
+ if (deadlinePassed) {
187
+ if (percentBefore !== undefined && percentAfter > percentBefore) return null;
188
+ // An expired deadline alone is NOT enough. src/codex/quota.ts:323-329 carries the
189
+ // previous burst tuple forward verbatim when a header write omits it, so a partial
190
+ // write reproduces the old deadline and the old percent exactly. Once wall-clock passes
191
+ // that copied deadline, "the clock expired" would fire on a snapshot where upstream
192
+ // said nothing at all — a false positive on the highest-frequency write path in the
193
+ // system (one per pooled response).
194
+ //
195
+ // Require corroboration that the window actually turned over: either usage fell, or
196
+ // upstream issued a new deadline. A byte-identical carried-forward window gives
197
+ // neither, so it stays silent.
198
+ const usageFell = percentBefore !== undefined && percentAfter < percentBefore;
199
+ const deadlineAdvanced = resetAt !== undefined && resetAt > previousResetAt!;
200
+ if (!usageFell && !deadlineAdvanced) return null;
201
+ return build("scheduled");
202
+ }
203
+
204
+ // Still inside the previous window, so quota coming back means upstream moved the window
205
+ // out of band.
206
+ //
207
+ // A material percent DROP is the only accepted evidence here. An advancing deadline is
208
+ // deliberately NOT sufficient, even though it looks like a fresh window: a ROLLING window
209
+ // (Anthropic's five_hour, Codex's burst window) reports a deadline that creeps forward on
210
+ // every poll by exactly the elapsed time, so "the deadline advanced" is true of every
211
+ // healthy observation of a rolling window and would fire continuously.
212
+ //
213
+ // Nothing is lost by requiring the drop. A surprise reset is worth telling an operator
214
+ // about because quota came BACK; if usage did not fall, none did, and there is nothing to
215
+ // report. The scheduled branch above can still accept an advancing deadline as evidence,
216
+ // because there the previous deadline had genuinely expired.
217
+ if (
218
+ previousResetAt !== undefined
219
+ && percentBefore !== undefined
220
+ && percentBefore - percentAfter >= MIN_SURPRISE_DROP_PERCENT
221
+ && !isRollingWindowCreep({
222
+ previousResetAt,
223
+ resetAt,
224
+ previousObservedAt: previous.observedAt,
225
+ now,
226
+ })
227
+ ) {
228
+ return build("surprise");
229
+ }
230
+
231
+ // A window with no deadline on either side is not evaluated. Several provider parsers
232
+ // never emit a reset clock at all (credit balances, prepaid pools), and a bare percent
233
+ // drop there is as likely to be a top-up or a plan change as a window rollover. Firing on
234
+ // it would make the channel noise; the honest answer is that those providers expose no
235
+ // window semantics to detect.
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * True when the deadline merely CREPT forward with the clock, which is what a rolling window
241
+ * does while nothing resets.
242
+ *
243
+ * A rolling window (Anthropic five_hour, Codex burst) has no rollover instant: usage ages out
244
+ * continuously, so its percent falls on its own and its deadline slides forward by roughly the
245
+ * elapsed time on every poll. MIN_SURPRISE_DROP_PERCENT only screens integer rounding, so a
246
+ * healthy rolling window fired false "surprise" events — measured: 88% -> 61% one hour into a
247
+ * 5h window, a 27-point drop with no reset involved.
248
+ *
249
+ * The discriminator is deadline MOVEMENT against elapsed time, not drop magnitude. Decay
250
+ * magnitude cannot be bounded from elapsed time alone, because the percent that ages out
251
+ * depends on WHEN the usage happened: an hour of idling can retire a large burst that all
252
+ * landed in one minute. Deadline movement behaves differently — while a window is merely
253
+ * rolling, its deadline advances by about the elapsed time, whereas a genuine out-of-band
254
+ * reset issues a deadline a FULL window into the future, jumping far beyond the elapsed gap.
255
+ *
256
+ * Fails OPEN (returns false, letting the event through) whenever the evidence is missing: no
257
+ * baseline timestamp, no deadline on either side, or a deadline that moved backwards. A
258
+ * missed reset is an inconvenience; a suppressed one on an unproven guess is a defect.
259
+ */
260
+ function isRollingWindowCreep(input: {
261
+ readonly previousResetAt: number | undefined;
262
+ readonly resetAt: number | undefined;
263
+ readonly previousObservedAt: number | undefined;
264
+ readonly now: number;
265
+ }): boolean {
266
+ const observedAt = finiteResetAt(input.previousObservedAt);
267
+ const previousResetAt = input.previousResetAt;
268
+ const resetAt = input.resetAt;
269
+ if (observedAt === undefined || previousResetAt === undefined || resetAt === undefined) {
270
+ return false;
271
+ }
272
+ const elapsedMs = input.now - observedAt;
273
+ if (elapsedMs <= 0) return false;
274
+ const deadlineShiftMs = resetAt - previousResetAt;
275
+ // A deadline that stood still or moved backwards is not creep. Standing still while usage
276
+ // fell is the clearest possible surprise-reset signature, so it must reach the operator.
277
+ if (deadlineShiftMs <= 0) return false;
278
+ // Creep tracks the clock. The 2x tolerance absorbs polling jitter and upstream rounding to
279
+ // whole minutes without approaching a real reset, which shifts the deadline by a whole
280
+ // window — hours, against a gap that is minutes on the observation paths that exist here.
281
+ return deadlineShiftMs <= elapsedMs * 2;
282
+ }
283
+
284
+ /** Pair two window lists by identity and yield every detected reset. */
285
+ export function detectQuotaResets(input: {
286
+ readonly scope: string;
287
+ readonly accountTag: string;
288
+ readonly previous: ReadonlyArray<QuotaWindowObservation>;
289
+ readonly next: ReadonlyArray<QuotaWindowObservation>;
290
+ readonly now: number;
291
+ }): QuotaResetEvent[] {
292
+ const before = new Map(input.previous.map(item => [item.window, item]));
293
+ const events: QuotaResetEvent[] = [];
294
+ for (const observation of input.next) {
295
+ const event = detectQuotaReset({
296
+ scope: input.scope,
297
+ accountTag: input.accountTag,
298
+ previous: before.get(observation.window),
299
+ next: observation,
300
+ now: input.now,
301
+ });
302
+ if (event) events.push(event);
303
+ }
304
+ return events;
305
+ }