codexLB-up 1.25.0b10__py3-none-any.whl

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 (827) hide show
  1. app/__init__.py +10 -0
  2. app/admin_cli.py +494 -0
  3. app/cli.py +339 -0
  4. app/codex_sessions_retag.py +487 -0
  5. app/core/__init__.py +0 -0
  6. app/core/audit/__init__.py +0 -0
  7. app/core/audit/service.py +216 -0
  8. app/core/audit/types.py +94 -0
  9. app/core/auth/__init__.py +236 -0
  10. app/core/auth/api_key_cache.py +65 -0
  11. app/core/auth/dashboard_access.py +429 -0
  12. app/core/auth/dashboard_mode.py +171 -0
  13. app/core/auth/dashboard_session_ttl.py +74 -0
  14. app/core/auth/dashboard_users_cache.py +120 -0
  15. app/core/auth/dependencies.py +710 -0
  16. app/core/auth/external_identity.py +78 -0
  17. app/core/auth/guardian.py +374 -0
  18. app/core/auth/models.py +49 -0
  19. app/core/auth/providers/__init__.py +129 -0
  20. app/core/auth/providers/oidc.py +838 -0
  21. app/core/auth/providers/registry.py +118 -0
  22. app/core/auth/refresh.py +394 -0
  23. app/core/auth/step_up.py +117 -0
  24. app/core/auth/totp.py +81 -0
  25. app/core/balancer/__init__.py +71 -0
  26. app/core/balancer/logic.py +1612 -0
  27. app/core/balancer/rendezvous_hash.py +21 -0
  28. app/core/balancer/types.py +21 -0
  29. app/core/bootstrap.py +172 -0
  30. app/core/cache/invalidation.py +426 -0
  31. app/core/clients/__init__.py +0 -0
  32. app/core/clients/codex.py +996 -0
  33. app/core/clients/codex_version.py +187 -0
  34. app/core/clients/codex_version_snapshot.py +2 -0
  35. app/core/clients/files.py +534 -0
  36. app/core/clients/headers.py +29 -0
  37. app/core/clients/http.py +511 -0
  38. app/core/clients/model_fetcher.py +276 -0
  39. app/core/clients/native_egress.py +1304 -0
  40. app/core/clients/oauth.py +477 -0
  41. app/core/clients/proxy.py +6089 -0
  42. app/core/clients/proxy_websocket.py +1411 -0
  43. app/core/clients/rate_limit_reset_credits.py +412 -0
  44. app/core/clients/stream_errors.py +12 -0
  45. app/core/clients/thread_cache_identity.py +347 -0
  46. app/core/clients/usage.py +467 -0
  47. app/core/clock.py +170 -0
  48. app/core/config/__init__.py +0 -0
  49. app/core/config/background_jobs.py +73 -0
  50. app/core/config/context_window_overrides.py +166 -0
  51. app/core/config/dashboard_overrides.py +145 -0
  52. app/core/config/inheritable.py +47 -0
  53. app/core/config/key_fingerprint.py +115 -0
  54. app/core/config/settings.py +872 -0
  55. app/core/config/settings_cache.py +100 -0
  56. app/core/config/spool_retention.py +217 -0
  57. app/core/config/tiers.py +174 -0
  58. app/core/conversation_archive.py +568 -0
  59. app/core/crypto.py +37 -0
  60. app/core/draining.py +11 -0
  61. app/core/errors.py +365 -0
  62. app/core/exceptions.py +155 -0
  63. app/core/handlers/__init__.py +3 -0
  64. app/core/handlers/exceptions.py +478 -0
  65. app/core/http_protocol.py +176 -0
  66. app/core/http_protocol_httptools.py +204 -0
  67. app/core/ingress_limits.py +17 -0
  68. app/core/metadata_store.py +18 -0
  69. app/core/metrics/__init__.py +0 -0
  70. app/core/metrics/middleware.py +74 -0
  71. app/core/metrics/prometheus.py +604 -0
  72. app/core/middleware/__init__.py +25 -0
  73. app/core/middleware/api_firewall.py +111 -0
  74. app/core/middleware/app_version.py +37 -0
  75. app/core/middleware/dashboard_auth_proxy.py +55 -0
  76. app/core/middleware/dashboard_csrf.py +177 -0
  77. app/core/middleware/dashboard_gzip.py +41 -0
  78. app/core/middleware/dashboard_overrides.py +59 -0
  79. app/core/middleware/firewall_cache.py +91 -0
  80. app/core/middleware/inflight.py +126 -0
  81. app/core/middleware/multipart_content_encoding.py +129 -0
  82. app/core/middleware/path_rewrite.py +148 -0
  83. app/core/middleware/request_body_limit.py +196 -0
  84. app/core/middleware/request_decompression.py +209 -0
  85. app/core/middleware/request_id.py +72 -0
  86. app/core/middleware/required_capability_http.py +116 -0
  87. app/core/middleware/trusted_proxy_headers.py +36 -0
  88. app/core/multipart.py +237 -0
  89. app/core/multipart_fields.py +91 -0
  90. app/core/openai/__init__.py +0 -0
  91. app/core/openai/chat_requests.py +493 -0
  92. app/core/openai/chat_responses.py +931 -0
  93. app/core/openai/contracts.py +46 -0
  94. app/core/openai/exceptions.py +24 -0
  95. app/core/openai/host_models.py +14 -0
  96. app/core/openai/images.py +413 -0
  97. app/core/openai/message_coercion.py +376 -0
  98. app/core/openai/model_refresh_scheduler.py +423 -0
  99. app/core/openai/model_registry.py +997 -0
  100. app/core/openai/model_registry_store.py +548 -0
  101. app/core/openai/models.py +172 -0
  102. app/core/openai/parsing.py +97 -0
  103. app/core/openai/requests.py +1980 -0
  104. app/core/openai/strict_schema.py +199 -0
  105. app/core/openai/tool_call_safety.py +99 -0
  106. app/core/openai/v1_requests.py +187 -0
  107. app/core/plan_types.py +90 -0
  108. app/core/prestop.py +202 -0
  109. app/core/rate_limiter/__init__.py +0 -0
  110. app/core/rate_limiter/db_rate_limiter.py +215 -0
  111. app/core/request_locality.py +473 -0
  112. app/core/resilience/__init__.py +0 -0
  113. app/core/resilience/backpressure.py +57 -0
  114. app/core/resilience/bulkhead.py +202 -0
  115. app/core/resilience/circuit_breaker.py +150 -0
  116. app/core/resilience/degradation.py +55 -0
  117. app/core/resilience/loop_lag_monitor.py +60 -0
  118. app/core/resilience/memory_monitor.py +137 -0
  119. app/core/resilience/network_recovery.py +256 -0
  120. app/core/resilience/overload.py +98 -0
  121. app/core/resilience/toggles.py +114 -0
  122. app/core/retention/__init__.py +0 -0
  123. app/core/retention/job.py +240 -0
  124. app/core/retention/scheduler.py +69 -0
  125. app/core/runtime_logging.py +601 -0
  126. app/core/scheduling/__init__.py +0 -0
  127. app/core/scheduling/leader_election.py +993 -0
  128. app/core/scheduling/leader_election_handle.py +25 -0
  129. app/core/server.py +140 -0
  130. app/core/shutdown.py +326 -0
  131. app/core/socket_peer.py +22 -0
  132. app/core/startup.py +41 -0
  133. app/core/timeout_invariants.py +347 -0
  134. app/core/tracing/__init__.py +0 -0
  135. app/core/tracing/otel.py +207 -0
  136. app/core/types.py +6 -0
  137. app/core/upstream_proxy/__init__.py +13 -0
  138. app/core/upstream_proxy/cache.py +130 -0
  139. app/core/upstream_proxy/resolver.py +189 -0
  140. app/core/upstream_proxy/types.py +104 -0
  141. app/core/usage/__init__.py +407 -0
  142. app/core/usage/depletion.py +109 -0
  143. app/core/usage/live_hub.py +41 -0
  144. app/core/usage/live_snapshots.py +174 -0
  145. app/core/usage/logs.py +212 -0
  146. app/core/usage/metadata_scheduler.py +118 -0
  147. app/core/usage/models.py +54 -0
  148. app/core/usage/pricing.py +576 -0
  149. app/core/usage/pricing_catalog.py +241 -0
  150. app/core/usage/pricing_snapshot.json +821 -0
  151. app/core/usage/quota.py +133 -0
  152. app/core/usage/refresh_policy.py +21 -0
  153. app/core/usage/refresh_scheduler.py +713 -0
  154. app/core/usage/reset_credits_refresh_scheduler.py +435 -0
  155. app/core/usage/types.py +168 -0
  156. app/core/utils/__init__.py +0 -0
  157. app/core/utils/json_guards.py +18 -0
  158. app/core/utils/locks.py +33 -0
  159. app/core/utils/masking.py +20 -0
  160. app/core/utils/proxy_env.py +104 -0
  161. app/core/utils/request_id.py +65 -0
  162. app/core/utils/retry.py +77 -0
  163. app/core/utils/shared_future.py +172 -0
  164. app/core/utils/sse.py +286 -0
  165. app/core/utils/time.py +24 -0
  166. app/db/__init__.py +0 -0
  167. app/db/account_identity_lock.py +56 -0
  168. app/db/alembic/__init__.py +1 -0
  169. app/db/alembic/env.py +70 -0
  170. app/db/alembic/revision_ids.py +43 -0
  171. app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
  172. app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
  173. app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
  174. app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
  175. app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
  176. app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
  177. app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
  178. app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
  179. app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
  180. app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
  181. app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
  182. app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
  183. app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
  184. app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
  185. app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
  186. app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
  187. app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
  188. app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
  189. app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
  190. app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
  191. app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
  192. app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
  193. app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
  194. app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
  195. app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
  196. app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
  197. app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
  198. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
  199. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
  200. app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
  201. app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
  202. app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
  203. app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
  204. app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
  205. app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
  206. app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
  207. app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
  208. app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
  209. app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
  210. app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
  211. app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
  212. app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
  213. app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
  214. app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
  215. app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
  216. app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
  217. app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
  218. app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
  219. app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
  220. app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
  221. app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
  222. app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
  223. app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
  224. app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
  225. app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
  226. app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
  227. app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
  228. app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
  229. app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
  230. app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
  231. app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
  232. app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
  233. app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
  234. app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
  235. app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
  236. app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
  237. app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
  238. app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
  239. app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
  240. app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
  241. app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
  242. app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
  243. app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
  244. app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
  245. app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
  246. app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
  247. app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
  248. app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
  249. app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
  250. app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
  251. app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
  252. app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
  253. app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
  254. app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
  255. app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
  256. app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
  257. app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
  258. app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
  259. app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
  260. app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
  261. app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
  262. app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
  263. app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
  264. app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
  265. app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
  266. app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
  267. app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
  268. app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
  269. app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
  270. app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
  271. app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
  272. app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
  273. app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
  274. app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
  275. app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
  276. app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
  277. app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
  278. app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
  279. app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
  280. app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
  281. app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
  282. app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
  283. app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
  284. app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
  285. app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
  286. app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
  287. app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
  288. app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
  289. app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
  290. app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
  291. app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
  292. app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
  293. app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
  294. app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
  295. app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
  296. app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
  297. app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
  298. app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
  299. app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
  300. app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
  301. app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
  302. app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
  303. app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
  304. app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
  305. app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
  306. app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
  307. app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
  308. app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
  309. app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
  310. app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
  311. app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
  312. app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
  313. app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
  314. app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
  315. app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
  316. app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
  317. app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
  318. app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
  319. app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
  320. app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
  321. app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
  322. app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
  323. app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
  324. app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
  325. app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
  326. app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
  327. app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
  328. app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
  329. app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
  330. app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
  331. app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
  332. app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
  333. app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
  334. app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
  335. app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
  336. app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
  337. app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
  338. app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
  339. app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
  340. app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
  341. app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
  342. app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
  343. app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
  344. app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
  345. app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
  346. app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
  347. app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
  348. app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
  349. app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
  350. app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
  351. app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
  352. app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
  353. app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
  354. app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
  355. app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
  356. app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
  357. app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
  358. app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
  359. app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
  360. app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
  361. app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
  362. app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
  363. app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
  364. app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
  365. app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
  366. app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
  367. app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
  368. app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
  369. app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
  370. app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
  371. app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
  372. app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
  373. app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
  374. app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
  375. app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
  376. app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
  377. app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
  378. app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
  379. app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
  380. app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
  381. app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
  382. app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
  383. app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
  384. app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
  385. app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
  386. app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
  387. app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
  388. app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
  389. app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
  390. app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
  391. app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
  392. app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
  393. app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
  394. app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
  395. app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
  396. app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
  397. app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
  398. app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
  399. app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
  400. app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
  401. app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
  402. app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
  403. app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
  404. app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
  405. app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
  406. app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
  407. app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
  408. app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
  409. app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
  410. app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
  411. app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
  412. app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
  413. app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
  414. app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
  415. app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
  416. app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
  417. app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
  418. app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
  419. app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
  420. app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
  421. app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
  422. app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
  423. app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
  424. app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
  425. app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
  426. app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
  427. app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
  428. app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
  429. app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
  430. app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
  431. app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
  432. app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
  433. app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
  434. app/db/alembic/versions/__init__.py +1 -0
  435. app/db/backup.py +64 -0
  436. app/db/migrate.py +1249 -0
  437. app/db/migration_lock.py +160 -0
  438. app/db/migration_url.py +18 -0
  439. app/db/models.py +3100 -0
  440. app/db/recover.py +244 -0
  441. app/db/session.py +1295 -0
  442. app/db/snapshot.py +46 -0
  443. app/db/sqlite_lock_retry.py +190 -0
  444. app/db/sqlite_utils.py +549 -0
  445. app/dependencies.py +462 -0
  446. app/main.py +1240 -0
  447. app/modules/__init__.py +0 -0
  448. app/modules/accounts/__init__.py +0 -0
  449. app/modules/accounts/api.py +426 -0
  450. app/modules/accounts/auth_manager.py +1439 -0
  451. app/modules/accounts/background_repository.py +160 -0
  452. app/modules/accounts/deletion.py +540 -0
  453. app/modules/accounts/mappers.py +654 -0
  454. app/modules/accounts/refresh_claims.py +352 -0
  455. app/modules/accounts/repository.py +1679 -0
  456. app/modules/accounts/schemas.py +308 -0
  457. app/modules/accounts/service.py +809 -0
  458. app/modules/accounts/usage_rollup.py +486 -0
  459. app/modules/accounts/usage_rollup_scheduler.py +75 -0
  460. app/modules/accounts/usage_time_rollup.py +1037 -0
  461. app/modules/accounts/usage_time_rollup_read.py +522 -0
  462. app/modules/api_keys/__init__.py +1 -0
  463. app/modules/api_keys/api.py +344 -0
  464. app/modules/api_keys/last_used_coalescer.py +216 -0
  465. app/modules/api_keys/limit_windows.py +41 -0
  466. app/modules/api_keys/repository.py +1200 -0
  467. app/modules/api_keys/reset_scheduler.py +85 -0
  468. app/modules/api_keys/schemas.py +135 -0
  469. app/modules/api_keys/service.py +2123 -0
  470. app/modules/audit/__init__.py +0 -0
  471. app/modules/audit/api.py +88 -0
  472. app/modules/audit/repository.py +74 -0
  473. app/modules/audit/schemas.py +33 -0
  474. app/modules/audit/service.py +71 -0
  475. app/modules/auth_providers/__init__.py +0 -0
  476. app/modules/auth_providers/api.py +127 -0
  477. app/modules/auth_providers/config.py +343 -0
  478. app/modules/auth_providers/repository.py +101 -0
  479. app/modules/auth_providers/schemas.py +88 -0
  480. app/modules/auth_providers/seed.py +105 -0
  481. app/modules/auth_providers/service.py +289 -0
  482. app/modules/automations/__init__.py +1 -0
  483. app/modules/automations/api.py +369 -0
  484. app/modules/automations/repository.py +1896 -0
  485. app/modules/automations/scheduler.py +107 -0
  486. app/modules/automations/schemas.py +158 -0
  487. app/modules/automations/service.py +2548 -0
  488. app/modules/cache_isolation_probe/__init__.py +0 -0
  489. app/modules/cache_isolation_probe/api.py +188 -0
  490. app/modules/cache_isolation_probe/prefix.py +144 -0
  491. app/modules/cache_isolation_probe/schemas.py +94 -0
  492. app/modules/cache_isolation_probe/sender.py +229 -0
  493. app/modules/cache_isolation_probe/service.py +525 -0
  494. app/modules/conversation_archive/__init__.py +1 -0
  495. app/modules/conversation_archive/api.py +101 -0
  496. app/modules/conversation_archive/schemas.py +28 -0
  497. app/modules/conversation_archive/service.py +165 -0
  498. app/modules/dashboard/__init__.py +1 -0
  499. app/modules/dashboard/api.py +106 -0
  500. app/modules/dashboard/builders.py +107 -0
  501. app/modules/dashboard/repository.py +304 -0
  502. app/modules/dashboard/schemas.py +148 -0
  503. app/modules/dashboard/service.py +534 -0
  504. app/modules/dashboard/timeframes.py +19 -0
  505. app/modules/dashboard/weekly_pace.py +617 -0
  506. app/modules/dashboard_auth/__init__.py +1 -0
  507. app/modules/dashboard_auth/api.py +1344 -0
  508. app/modules/dashboard_auth/oidc_api.py +753 -0
  509. app/modules/dashboard_auth/oidc_flows.py +330 -0
  510. app/modules/dashboard_auth/repository.py +452 -0
  511. app/modules/dashboard_auth/schemas.py +232 -0
  512. app/modules/dashboard_auth/service.py +1384 -0
  513. app/modules/dashboard_roles/__init__.py +0 -0
  514. app/modules/dashboard_roles/api.py +64 -0
  515. app/modules/dashboard_roles/repository.py +54 -0
  516. app/modules/dashboard_roles/schemas.py +29 -0
  517. app/modules/dashboard_roles/seed.py +80 -0
  518. app/modules/dashboard_roles/service.py +142 -0
  519. app/modules/dashboard_users/__init__.py +0 -0
  520. app/modules/dashboard_users/api.py +278 -0
  521. app/modules/dashboard_users/break_glass.py +169 -0
  522. app/modules/dashboard_users/credentials.py +18 -0
  523. app/modules/dashboard_users/identity_resolver.py +665 -0
  524. app/modules/dashboard_users/repository.py +725 -0
  525. app/modules/dashboard_users/schemas.py +117 -0
  526. app/modules/dashboard_users/service.py +1054 -0
  527. app/modules/firewall/__init__.py +1 -0
  528. app/modules/firewall/api.py +78 -0
  529. app/modules/firewall/repository.py +51 -0
  530. app/modules/firewall/schemas.py +28 -0
  531. app/modules/firewall/service.py +93 -0
  532. app/modules/fleet/__init__.py +1 -0
  533. app/modules/fleet/api.py +177 -0
  534. app/modules/fleet/mappers.py +56 -0
  535. app/modules/fleet/observability.py +372 -0
  536. app/modules/fleet/schemas.py +119 -0
  537. app/modules/health/__init__.py +0 -0
  538. app/modules/health/api.py +246 -0
  539. app/modules/health/schemas.py +27 -0
  540. app/modules/limit_warmup/__init__.py +1 -0
  541. app/modules/limit_warmup/repository.py +159 -0
  542. app/modules/limit_warmup/service.py +1001 -0
  543. app/modules/model_sources/__init__.py +1 -0
  544. app/modules/model_sources/api.py +99 -0
  545. app/modules/model_sources/catalog.py +307 -0
  546. app/modules/model_sources/forwarding.py +1435 -0
  547. app/modules/model_sources/projection.py +55 -0
  548. app/modules/model_sources/repository.py +201 -0
  549. app/modules/model_sources/schemas.py +79 -0
  550. app/modules/model_sources/selection.py +166 -0
  551. app/modules/model_sources/service.py +236 -0
  552. app/modules/oauth/__init__.py +0 -0
  553. app/modules/oauth/api.py +101 -0
  554. app/modules/oauth/repository.py +238 -0
  555. app/modules/oauth/schemas.py +49 -0
  556. app/modules/oauth/service.py +1196 -0
  557. app/modules/oauth/templates/oauth_success.html +122 -0
  558. app/modules/proxy/__init__.py +0 -0
  559. app/modules/proxy/_load_balancer/__init__.py +1 -0
  560. app/modules/proxy/_load_balancer/error_rate.py +116 -0
  561. app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
  562. app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
  563. app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
  564. app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
  565. app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
  566. app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
  567. app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
  568. app/modules/proxy/_load_balancer/tunables.py +97 -0
  569. app/modules/proxy/_load_balancer/types.py +96 -0
  570. app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
  571. app/modules/proxy/_service/__init__.py +6 -0
  572. app/modules/proxy/_service/api_key_usage.py +745 -0
  573. app/modules/proxy/_service/clock_budget.py +38 -0
  574. app/modules/proxy/_service/codex_control.py +611 -0
  575. app/modules/proxy/_service/compact.py +2211 -0
  576. app/modules/proxy/_service/file_ops.py +749 -0
  577. app/modules/proxy/_service/http_bridge/__init__.py +3 -0
  578. app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
  579. app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
  580. app/modules/proxy/_service/http_bridge/activity.py +178 -0
  581. app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
  582. app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
  583. app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
  584. app/modules/proxy/_service/http_bridge/protocol.py +167 -0
  585. app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
  586. app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
  587. app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
  588. app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
  589. app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
  590. app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
  591. app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
  592. app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
  593. app/modules/proxy/_service/observability.py +410 -0
  594. app/modules/proxy/_service/rate_limit.py +361 -0
  595. app/modules/proxy/_service/realtime_live.py +655 -0
  596. app/modules/proxy/_service/refresh.py +154 -0
  597. app/modules/proxy/_service/request_log.py +585 -0
  598. app/modules/proxy/_service/response_create.py +930 -0
  599. app/modules/proxy/_service/streaming/__init__.py +3 -0
  600. app/modules/proxy/_service/streaming/helpers.py +1317 -0
  601. app/modules/proxy/_service/streaming/mixin.py +1100 -0
  602. app/modules/proxy/_service/streaming/protocol.py +32 -0
  603. app/modules/proxy/_service/streaming/retry.py +3762 -0
  604. app/modules/proxy/_service/support.py +2122 -0
  605. app/modules/proxy/_service/transcribe.py +434 -0
  606. app/modules/proxy/_service/warmup.py +483 -0
  607. app/modules/proxy/_service/websocket/__init__.py +3 -0
  608. app/modules/proxy/_service/websocket/helpers.py +2360 -0
  609. app/modules/proxy/_service/websocket/mixin.py +7285 -0
  610. app/modules/proxy/_service/websocket/protocol.py +74 -0
  611. app/modules/proxy/account_cache.py +229 -0
  612. app/modules/proxy/account_eligibility.py +87 -0
  613. app/modules/proxy/additional_model_limits.py +40 -0
  614. app/modules/proxy/affinity.py +830 -0
  615. app/modules/proxy/affinity_observation.py +101 -0
  616. app/modules/proxy/api.py +10254 -0
  617. app/modules/proxy/api_key_usage.py +113 -0
  618. app/modules/proxy/cap_partitioning.py +280 -0
  619. app/modules/proxy/capability_lineage.py +65 -0
  620. app/modules/proxy/capability_lineage_repository.py +82 -0
  621. app/modules/proxy/capability_routing.py +253 -0
  622. app/modules/proxy/continuity.py +112 -0
  623. app/modules/proxy/downstream_delivery.py +277 -0
  624. app/modules/proxy/durable_bridge_coordinator.py +1217 -0
  625. app/modules/proxy/durable_bridge_repository.py +4516 -0
  626. app/modules/proxy/durable_bridge_runtime.py +9 -0
  627. app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
  628. app/modules/proxy/fair_share.py +131 -0
  629. app/modules/proxy/file_pin_repository.py +210 -0
  630. app/modules/proxy/helpers.py +439 -0
  631. app/modules/proxy/http_bridge_event_batcher.py +613 -0
  632. app/modules/proxy/http_bridge_forwarding.py +797 -0
  633. app/modules/proxy/http_continuation.py +76 -0
  634. app/modules/proxy/images_observability.py +62 -0
  635. app/modules/proxy/images_service.py +1054 -0
  636. app/modules/proxy/load_balancer.py +3016 -0
  637. app/modules/proxy/rate_limit_cache.py +46 -0
  638. app/modules/proxy/replay_safety.py +1063 -0
  639. app/modules/proxy/repo_bundle.py +31 -0
  640. app/modules/proxy/request_policy.py +1054 -0
  641. app/modules/proxy/ring_membership.py +251 -0
  642. app/modules/proxy/schemas.py +345 -0
  643. app/modules/proxy/selection_errors.py +41 -0
  644. app/modules/proxy/service.py +2600 -0
  645. app/modules/proxy/source_admission.py +125 -0
  646. app/modules/proxy/source_dispatch.py +999 -0
  647. app/modules/proxy/sticky_repository.py +831 -0
  648. app/modules/proxy/thread_anchors.py +491 -0
  649. app/modules/proxy/tool_call_dedupe.py +802 -0
  650. app/modules/proxy/types.py +54 -0
  651. app/modules/proxy/work_admission.py +121 -0
  652. app/modules/quota_planner/__init__.py +1 -0
  653. app/modules/quota_planner/api.py +276 -0
  654. app/modules/quota_planner/logic.py +743 -0
  655. app/modules/quota_planner/repository.py +722 -0
  656. app/modules/quota_planner/scheduler.py +212 -0
  657. app/modules/quota_planner/schemas.py +102 -0
  658. app/modules/quota_planner/warmup.py +693 -0
  659. app/modules/rate_limit_reset_credits/__init__.py +0 -0
  660. app/modules/rate_limit_reset_credits/api.py +601 -0
  661. app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
  662. app/modules/rate_limit_reset_credits/store.py +114 -0
  663. app/modules/reports/__init__.py +1 -0
  664. app/modules/reports/api.py +134 -0
  665. app/modules/reports/cache.py +74 -0
  666. app/modules/reports/filters.py +27 -0
  667. app/modules/reports/repository.py +525 -0
  668. app/modules/reports/rollup.py +150 -0
  669. app/modules/reports/rollup_read.py +148 -0
  670. app/modules/reports/schemas.py +123 -0
  671. app/modules/reports/service.py +287 -0
  672. app/modules/reports/thread_identity.py +256 -0
  673. app/modules/request_logs/__init__.py +0 -0
  674. app/modules/request_logs/api.py +224 -0
  675. app/modules/request_logs/cost_backfill.py +154 -0
  676. app/modules/request_logs/mappers.py +97 -0
  677. app/modules/request_logs/repository.py +1765 -0
  678. app/modules/request_logs/schemas.py +145 -0
  679. app/modules/request_logs/service.py +348 -0
  680. app/modules/role_mappings/__init__.py +0 -0
  681. app/modules/role_mappings/api.py +206 -0
  682. app/modules/role_mappings/matching.py +55 -0
  683. app/modules/role_mappings/repository.py +106 -0
  684. app/modules/role_mappings/schemas.py +67 -0
  685. app/modules/role_mappings/service.py +334 -0
  686. app/modules/runtime/__init__.py +1 -0
  687. app/modules/runtime/api.py +18 -0
  688. app/modules/runtime/schemas.py +14 -0
  689. app/modules/runtime/service.py +218 -0
  690. app/modules/scim/__init__.py +1 -0
  691. app/modules/scim/api.py +194 -0
  692. app/modules/scim/dependencies.py +112 -0
  693. app/modules/scim/errors.py +77 -0
  694. app/modules/scim/management_api.py +156 -0
  695. app/modules/scim/repository.py +212 -0
  696. app/modules/scim/schemas.py +145 -0
  697. app/modules/scim/service.py +786 -0
  698. app/modules/scim/tokens.py +43 -0
  699. app/modules/settings/__init__.py +1 -0
  700. app/modules/settings/api.py +1677 -0
  701. app/modules/settings/repository.py +606 -0
  702. app/modules/settings/schemas.py +496 -0
  703. app/modules/settings/service.py +769 -0
  704. app/modules/shared/__init__.py +0 -0
  705. app/modules/shared/schemas.py +22 -0
  706. app/modules/sticky_sessions/__init__.py +1 -0
  707. app/modules/sticky_sessions/api.py +118 -0
  708. app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
  709. app/modules/sticky_sessions/schemas.py +78 -0
  710. app/modules/sticky_sessions/service.py +196 -0
  711. app/modules/telemetry/__init__.py +5 -0
  712. app/modules/telemetry/api.py +115 -0
  713. app/modules/telemetry/clients.py +62 -0
  714. app/modules/telemetry/consent.py +115 -0
  715. app/modules/telemetry/scheduler.py +85 -0
  716. app/modules/telemetry/schemas.py +163 -0
  717. app/modules/telemetry/sender.py +221 -0
  718. app/modules/telemetry/snapshot.py +490 -0
  719. app/modules/usage/__init__.py +0 -0
  720. app/modules/usage/additional_quota_keys.py +379 -0
  721. app/modules/usage/api.py +45 -0
  722. app/modules/usage/background_repository.py +147 -0
  723. app/modules/usage/builders.py +559 -0
  724. app/modules/usage/depletion_service.py +277 -0
  725. app/modules/usage/live_ingest.py +356 -0
  726. app/modules/usage/mappers.py +29 -0
  727. app/modules/usage/plan_downgrade_observations.py +488 -0
  728. app/modules/usage/repository.py +1890 -0
  729. app/modules/usage/schemas.py +68 -0
  730. app/modules/usage/service.py +102 -0
  731. app/modules/usage/updater.py +1488 -0
  732. app/static/assets/Trans-Cj-J9mVA.js +1 -0
  733. app/static/assets/access-page-DHzZiXZi.js +1 -0
  734. app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
  735. app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
  736. app/static/assets/account-status-DIsU5sUS.js +1 -0
  737. app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
  738. app/static/assets/accounts-page-Bbai8ift.js +3 -0
  739. app/static/assets/api-Dwa7XxPe.js +1 -0
  740. app/static/assets/api-client-dugX1Pti.js +55 -0
  741. app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
  742. app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
  743. app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
  744. app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
  745. app/static/assets/apis-page-BvXz9c6i.js +2 -0
  746. app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
  747. app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
  748. app/static/assets/automations-page-DQgNAJOR.js +3 -0
  749. app/static/assets/badge-DqMb9es0.js +1 -0
  750. app/static/assets/blur-email-HEvKagG2.js +1 -0
  751. app/static/assets/button-BjVmmiGW.js +1 -0
  752. app/static/assets/checkbox-apsl9vzk.js +1 -0
  753. app/static/assets/chevron-left-1d_pHVa5.js +1 -0
  754. app/static/assets/circle-alert-BS0xd_o1.js +1 -0
  755. app/static/assets/clsx-DB0hHKMi.js +1 -0
  756. app/static/assets/colors-BBy8GEtr.js +1 -0
  757. app/static/assets/copy-button-D7nAvOKR.js +1 -0
  758. app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
  759. app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
  760. app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
  761. app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
  762. app/static/assets/donut-chart-Bxc69cra.js +1 -0
  763. app/static/assets/download-kQteIcsB.js +1 -0
  764. app/static/assets/ellipsis-B_5OgIre.js +1 -0
  765. app/static/assets/empty-state-JcA7tc8m.js +1 -0
  766. app/static/assets/es2015-bMH8YKZT.js +41 -0
  767. app/static/assets/es6-B264ni6W.js +69 -0
  768. app/static/assets/external-link-UVAOgMQ0.js +1 -0
  769. app/static/assets/i18n-BWqZ9Ua2.js +1 -0
  770. app/static/assets/inbox-B_RM2s1J.js +1 -0
  771. app/static/assets/index-DIpd264Z.js +23 -0
  772. app/static/assets/index-DMeLKRJx.css +2 -0
  773. app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
  774. app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
  775. app/static/assets/iso-Bpe5Ihbb.js +1 -0
  776. app/static/assets/label-CGaLmLNf.js +1 -0
  777. app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
  778. app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
  779. app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
  780. app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
  781. app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
  782. app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
  783. app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
  784. app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
  785. app/static/assets/payload-CIYnGZz9.js +1 -0
  786. app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
  787. app/static/assets/report-chart-card-BXDliwcc.js +1 -0
  788. app/static/assets/reports-page-CGCJeMHG.js +3 -0
  789. app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
  790. app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  791. app/static/assets/search-HfhqwERt.js +1 -0
  792. app/static/assets/select-Cl33bz-g.js +1 -0
  793. app/static/assets/settings-page-mZj01HYl.js +2 -0
  794. app/static/assets/shim-CZwyM4Q7.js +1 -0
  795. app/static/assets/skeleton-C_EUc4Rp.js +1 -0
  796. app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
  797. app/static/assets/status-badge-CHHA5X6c.js +1 -0
  798. app/static/assets/switch-CzxgokcM.js +1 -0
  799. app/static/assets/table-DXaNSQ0G.js +1 -0
  800. app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
  801. app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
  802. app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
  803. app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
  804. app/static/assets/trash-D3KyWr1h.js +1 -0
  805. app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
  806. app/static/assets/use-accounts-PWlVGw3z.js +1 -0
  807. app/static/assets/use-auth-CuDlW3iC.js +1 -0
  808. app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
  809. app/static/assets/use-model-sources-Bul24JBu.js +1 -0
  810. app/static/assets/use-models-DUCPUuZg.js +1 -0
  811. app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
  812. app/static/assets/useTranslation--56nHhlH.js +1 -0
  813. app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
  814. app/static/assets/users-uIC3TIlE.js +1 -0
  815. app/static/assets/vendor-query-_bHZuxEx.js +1 -0
  816. app/static/assets/vendor-react-BVL0YZPW.js +9 -0
  817. app/static/favicon.svg +10 -0
  818. app/static/fonts/GeistSans-Variable.woff2 +0 -0
  819. app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  820. app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  821. app/static/index.html +29 -0
  822. codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
  823. codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
  824. codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
  825. codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
  826. codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
  827. config/additional_quota_registry.json +11 -0
@@ -0,0 +1,1037 @@
1
+ """Time-axis request-usage rollups: schema primitives and repository.
2
+
3
+ Four permanent aggregate tables serve the dashboard/statistics read paths
4
+ without scanning raw ``request_logs``:
5
+
6
+ - ``request_usage_hourly_rollups`` — UTC hour buckets x (account_id,
7
+ api_key_id, model, service_tier, request_kind, is_deleted).
8
+ - ``request_usage_hourly_error_rollups`` — UTC hour buckets x (account_id,
9
+ error_code); the top-error satellite (unbounded cardinality isolated).
10
+ - ``request_demand_quarter_rollups`` — 900s slots x (account_id, api_key_id,
11
+ model, reasoning_effort, request_kind, status, is_deleted) for the quota
12
+ planner. The full legacy demand grain is preserved on purpose: the
13
+ planner's ``_bin_demand_units`` applies ``max()`` PER BIN before summing
14
+ (nonlinear), so folding to a coarser grain would change forecasts.
15
+ - ``request_conversation_hourly_rollups`` — UTC hour buckets x (normalized
16
+ conversation_id, account_id, is_deleted); the distinct-conversation
17
+ presence satellite. Folded on its OWN watermark and fold pass with the
18
+ shared read-side filter (normalized conversation id present, warmup kinds
19
+ excluded); the deleted split stays a dimension because the dashboard
20
+ conversation reads exclude soft-deleted rows while the reports reads
21
+ include them.
22
+
23
+ Watermark contract: ``account_usage_rollup_state.hourly_folded_through`` and
24
+ ``conversation_folded_through`` (the same single state row as the lifetime
25
+ fold, so one ``FOR UPDATE`` row lock serializes every fold and lifecycle
26
+ mutation) are ALWAYS aligned to a whole UTC hour. Raw rows with
27
+ ``requested_at < watermark`` are fully folded by that watermark's tables;
28
+ rows at or above it are the live tail. Buckets are half-open ``[start,
29
+ end)``.
30
+
31
+ NULL-dimension sentinel: nullable raw dimensions (account_id, api_key_id,
32
+ service_tier, reasoning_effort) are stored as ``'\\x1f'`` so they can
33
+ participate in the primary key on both dialects (UNIQUE/PK treat NULLs as
34
+ distinct rows on PostgreSQL and SQLite). The encoding is collision-free:
35
+ raw values that themselves start with the sentinel are escaped with one
36
+ more sentinel character, so the empty string — a legitimate value the
37
+ request models accept for service_tier/reasoning_effort, and a GROUP BY
38
+ group distinct from NULL in the legacy raw queries — round-trips intact.
39
+ Use :func:`to_dimension` / :func:`from_dimension` (SQL: ``_dimension_expr``)
40
+ at the write and read boundaries — a missed mapping silently diverges
41
+ rollups from raw.
42
+
43
+ History-rewrite discipline (MUST): any code path that mutates a folded
44
+ dimension (``requested_at``, ``deleted_at``, ``account_id``, ``api_key_id``,
45
+ ``model``, ``service_tier``, ``reasoning_effort``, ``request_kind``,
46
+ ``status``, ``error_code``, ``conversation_id``) or an aggregated measure
47
+ column of ``request_logs`` rows BELOW the relevant watermark must take
48
+ ``lock_fold_state()`` in the same transaction and either mirror the mutation
49
+ into every rollup table that folded it or skip the pre-watermark rows
50
+ (folded buckets are never recomputed from raw — raw may already be pruned by
51
+ retention). ``RequestLogsRepository.update_model_for_request`` takes the
52
+ skip route (and needs no conversation-satellite bound at all: neither
53
+ ``model`` nor ``cost_usd`` is folded there).
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import logging
59
+ from collections.abc import Iterable, Sequence
60
+ from dataclasses import astuple, dataclass, replace
61
+ from datetime import datetime, timedelta
62
+
63
+ from sqlalchemy import BigInteger, ColumnElement, Integer, and_, case, cast, delete, func, insert, select, true, update
64
+ from sqlalchemy.ext.asyncio import AsyncSession
65
+ from sqlalchemy.sql.elements import SQLCoreOperations
66
+
67
+ from app.core.usage.logs import CANCELLED_STATUS, NON_ERROR_STATUSES
68
+ from app.core.utils.time import utcnow
69
+ from app.db.models import (
70
+ AccountUsageRollupState,
71
+ RequestConversationHourlyRollup,
72
+ RequestDemandQuarterRollup,
73
+ RequestLog,
74
+ RequestUsageHourlyErrorRollup,
75
+ RequestUsageHourlyRollup,
76
+ )
77
+ from app.db.session import get_background_session, sqlite_writer_section
78
+ from app.modules.accounts.usage_rollup import (
79
+ _STATE_ROW_ID,
80
+ FOLD_LAG,
81
+ _FoldStatus,
82
+ _insert_fn,
83
+ _locked_state,
84
+ _state_bootstrap_stmt,
85
+ )
86
+
87
+ logger = logging.getLogger(__name__)
88
+
89
+ HOURLY_BUCKET_SECONDS = 3600
90
+ QUARTER_SLOT_SECONDS = 900
91
+
92
+ # Historical backfill folds at most this much history per slice transaction
93
+ # and at most TS_MAX_SLICES_PER_PASS slices per pass, so the initial backfill
94
+ # (millions of raw rows) spreads its I/O bursts and fold-state row-lock
95
+ # occupancy across scheduler ticks instead of one giant catch-up.
96
+ TS_FOLD_SLICE = timedelta(hours=48)
97
+ TS_MAX_SLICES_PER_PASS = 20
98
+
99
+ # Rolling-upgrade repair (#1552): the cancelled_count migration runs before
100
+ # old replicas drain, so a legacy leader can still fold hours with the old
101
+ # error fold (cancelled terminals counted as errors, cancelled_count 0,
102
+ # client_disconnected folded into the error satellite) and advance the shared
103
+ # watermark — buckets new code would otherwise never revisit. Old writers run
104
+ # old code and cannot be fenced, so the NEW code repairs instead: it refolds
105
+ # the legacy-suspect range `[upgrade_repair_from, hourly_folded_through)`
106
+ # from raw. The marker is stamped by the migration (existing rows get their
107
+ # then-current watermark; a state row bootstrapped by an OLD replica after
108
+ # the migration gets the epoch server default, marking its entire backfill
109
+ # suspect — a legacy leader can advance TS_MAX_SLICES_PER_PASS x
110
+ # TS_FOLD_SLICE per pass, so no fixed trailing window could cover it) and
111
+ # cleared to NULL only by new code once the range is refolded. Repair
112
+ # progress persists chunk-by-chunk through the marker, so a crash resumes
113
+ # instead of restarting.
114
+ #
115
+ # With the marker already NULL, each process's first fold pass still refolds
116
+ # this trailing window as flip-flop defense: an old replica that regains
117
+ # fold leadership AFTER the marker was cleared (mid-rollout) writes legacy
118
+ # buckets the marker no longer tracks. A steady-state legacy interlude
119
+ # advances the watermark by minutes-to-hours — far below this span — and the
120
+ # rollout that makes interludes possible also guarantees another new-code
121
+ # process (the old replica's replacement) starts afterwards and runs this
122
+ # defense. Cost: one backfill-slice-equivalent per process start.
123
+ #
124
+ # Both paths clamp to hours fully covered by surviving raw rows (retention
125
+ # prunes oldest-first with a contiguous frontier, so every row at or above
126
+ # the earliest surviving one is intact) — the repair can never erase folded
127
+ # statistics it cannot recompute; clamped-out buckets keep the disclosed
128
+ # legacy fold. Neither path is a historical backfill.
129
+ UPGRADE_REPAIR_WINDOW = timedelta(hours=48)
130
+ _upgrade_repair_done = False
131
+
132
+ _EPOCH = datetime(1970, 1, 1)
133
+
134
+ # Synthetic request kinds every statistics read path filters out. Folded
135
+ # verbatim as a dimension; readers exclude them bucket-side exactly as the
136
+ # legacy raw queries do row-side.
137
+ WARMUP_REQUEST_KINDS = ("warmup", "limit_warmup")
138
+ _EXCLUDED_REQUEST_KINDS = WARMUP_REQUEST_KINDS
139
+
140
+ # The whitespace set the conversation readers strip before comparing/counting
141
+ # conversation ids (str.strip()'s ASCII whitespace).
142
+ CONVERSATION_WHITESPACE = " \t\n\v\f\r"
143
+
144
+
145
+ def normalized_thread_key_expr(column: SQLCoreOperations[str | None]) -> ColumnElement:
146
+ """Trim a thread-identifying text column, collapsing blank to NULL.
147
+
148
+ The single SQL definition shared by the conversation fold and every
149
+ conversation reader (dashboard and reports repositories) — a drifted
150
+ variant would silently split one conversation into two. It is reused for
151
+ ``session_id`` so a session-keyed thread normalizes identically.
152
+ """
153
+ trimmed = func.ltrim(func.rtrim(column, CONVERSATION_WHITESPACE), CONVERSATION_WHITESPACE)
154
+ return func.nullif(trimmed, "")
155
+
156
+
157
+ def conversation_id_expr() -> ColumnElement:
158
+ """Normalized conversation id."""
159
+ return normalized_thread_key_expr(RequestLog.conversation_id)
160
+
161
+
162
+ # Stored stand-in for NULL account_id / api_key_id / service_tier /
163
+ # reasoning_effort (PK columns cannot be NULL, and NULLs would be distinct
164
+ # under a unique constraint). U+001F (unit separator) rather than '' because
165
+ # '' is a legitimate raw value (the request models accept empty-string
166
+ # service_tier/reasoning_effort) that the legacy GROUP BY treats as a group
167
+ # distinct from NULL; raw values that start with the sentinel are escaped
168
+ # with one more sentinel character so the mapping stays injective.
169
+ DIMENSION_SENTINEL = "\x1f"
170
+
171
+
172
+ def to_dimension(value: str | None) -> str:
173
+ """Map a nullable raw dimension value to its stored PK representation."""
174
+ if value is None:
175
+ return DIMENSION_SENTINEL
176
+ if value.startswith(DIMENSION_SENTINEL):
177
+ return DIMENSION_SENTINEL + value
178
+ return value
179
+
180
+
181
+ def from_dimension(value: str) -> str | None:
182
+ """Map a stored dimension value back to the raw (nullable) domain."""
183
+ if value == DIMENSION_SENTINEL:
184
+ return None
185
+ if value.startswith(DIMENSION_SENTINEL):
186
+ return value[len(DIMENSION_SENTINEL) :]
187
+ return value
188
+
189
+
190
+ def _dimension_expr(column) -> ColumnElement[str]:
191
+ """SQL mirror of :func:`to_dimension` for the fold INSERT..SELECTs."""
192
+ return case(
193
+ (column.is_(None), DIMENSION_SENTINEL),
194
+ (func.substr(column, 1, 1) == DIMENSION_SENTINEL, DIMENSION_SENTINEL + column),
195
+ else_=column,
196
+ )
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class HourlyUsageRollupRow:
201
+ bucket_epoch: int
202
+ account_id: str
203
+ api_key_id: str
204
+ model: str
205
+ service_tier: str
206
+ request_kind: str
207
+ is_deleted: bool
208
+ request_count: int = 0
209
+ error_count: int = 0
210
+ cancelled_count: int = 0
211
+ input_tokens: int = 0
212
+ output_tokens: int = 0
213
+ reasoning_tokens: int = 0
214
+ output_or_reasoning_tokens: int = 0
215
+ cached_input_tokens: int = 0
216
+ cached_input_tokens_clamped: int = 0
217
+ cost_usd: float = 0.0
218
+ cost_count: int = 0
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class HourlyErrorRollupRow:
223
+ bucket_epoch: int
224
+ account_id: str
225
+ error_code: str
226
+ error_count: int = 0
227
+
228
+
229
+ @dataclass(frozen=True, slots=True)
230
+ class QuarterDemandRollupRow:
231
+ slot_epoch: int
232
+ account_id: str
233
+ api_key_id: str
234
+ model: str
235
+ reasoning_effort: str
236
+ request_kind: str
237
+ status: str
238
+ is_deleted: bool
239
+ request_count: int = 0
240
+ input_tokens: int = 0
241
+ output_or_reasoning_tokens: int = 0
242
+ cached_input_tokens: int = 0
243
+ cost_usd: float = 0.0
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class ConversationHourlyRollupRow:
248
+ bucket_epoch: int
249
+ conversation_id: str
250
+ account_id: str
251
+ is_deleted: bool
252
+ request_count: int = 0
253
+
254
+
255
+ _HOURLY_KEY_COLUMNS = (
256
+ "bucket_epoch",
257
+ "account_id",
258
+ "api_key_id",
259
+ "model",
260
+ "service_tier",
261
+ "request_kind",
262
+ "is_deleted",
263
+ )
264
+ _HOURLY_MEASURE_COLUMNS = (
265
+ "request_count",
266
+ "error_count",
267
+ "cancelled_count",
268
+ "input_tokens",
269
+ "output_tokens",
270
+ "reasoning_tokens",
271
+ "output_or_reasoning_tokens",
272
+ "cached_input_tokens",
273
+ "cached_input_tokens_clamped",
274
+ "cost_usd",
275
+ "cost_count",
276
+ )
277
+ _ERROR_KEY_COLUMNS = ("bucket_epoch", "account_id", "error_code")
278
+ _ERROR_MEASURE_COLUMNS = ("error_count",)
279
+ _QUARTER_KEY_COLUMNS = (
280
+ "slot_epoch",
281
+ "account_id",
282
+ "api_key_id",
283
+ "model",
284
+ "reasoning_effort",
285
+ "request_kind",
286
+ "status",
287
+ "is_deleted",
288
+ )
289
+ _QUARTER_MEASURE_COLUMNS = (
290
+ "request_count",
291
+ "input_tokens",
292
+ "output_or_reasoning_tokens",
293
+ "cached_input_tokens",
294
+ "cost_usd",
295
+ )
296
+ _CONVERSATION_KEY_COLUMNS = ("bucket_epoch", "conversation_id", "account_id", "is_deleted")
297
+ _CONVERSATION_MEASURE_COLUMNS = ("request_count",)
298
+
299
+
300
+ def _merge_rows(rows: Iterable, key_width: int, columns: tuple[str, ...], row_type):
301
+ """Pre-merge rows sharing a PK so one multi-row INSERT never touches the
302
+ same conflict target twice (PostgreSQL rejects that outright)."""
303
+ merged: dict[tuple, list] = {}
304
+ for row in rows:
305
+ values = list(astuple(row))
306
+ key = tuple(values[:key_width])
307
+ existing = merged.get(key)
308
+ if existing is None:
309
+ merged[key] = values
310
+ else:
311
+ for index in range(key_width, len(columns)):
312
+ existing[index] += values[index]
313
+ return [row_type(*values) for values in merged.values()]
314
+
315
+
316
+ # Rows per multi-VALUES upsert statement. asyncpg rejects statements with
317
+ # more than 32,767 bind parameters; at 18 columns (the widest table) 1,000
318
+ # rows binds 18,000 — comfortable margin on both dialects. Lifecycle mirrors
319
+ # rekey an account's ENTIRE folded history in one call (thousands of rows
320
+ # for a long-lived account), so unchunked upserts would abort the whole
321
+ # lifecycle transaction.
322
+ _UPSERT_CHUNK_ROWS = 1_000
323
+
324
+
325
+ def _add_rows_stmt(
326
+ session: AsyncSession, model, rows: Sequence, key_columns: tuple[str, ...], columns: tuple[str, ...]
327
+ ):
328
+ measure_columns = columns[len(key_columns) :]
329
+ stmt = _insert_fn(session)(model).values([dict(zip(columns, astuple(row), strict=True)) for row in rows])
330
+ return stmt.on_conflict_do_update(
331
+ index_elements=[getattr(model, column) for column in key_columns],
332
+ set_={column: getattr(model, column) + getattr(stmt.excluded, column) for column in measure_columns},
333
+ )
334
+
335
+
336
+ async def _merge_add(
337
+ session: AsyncSession, model, rows: Sequence, key_columns: tuple[str, ...], columns: tuple[str, ...], row_type
338
+ ) -> None:
339
+ merged = _merge_rows(rows, len(key_columns), columns, row_type)
340
+ for start in range(0, len(merged), _UPSERT_CHUNK_ROWS):
341
+ chunk = merged[start : start + _UPSERT_CHUNK_ROWS]
342
+ await session.execute(_add_rows_stmt(session, model, chunk, key_columns, columns))
343
+
344
+
345
+ class RequestUsageTimeRollupRepository:
346
+ """Upsert (merge-add) and range reads for the three time-axis rollups.
347
+
348
+ Every read returns ``(rows, hourly watermark)`` from ONE statement
349
+ (state LEFT JOIN rollup): a single statement sees a single snapshot even
350
+ under READ COMMITTED, so a fold slice committing concurrently can never
351
+ yield rollup rows from one watermark generation and a watermark from
352
+ another. Callers derive the raw-tail window from the returned watermark.
353
+ Range bounds are half-open ``[since_epoch, until_epoch)``.
354
+ """
355
+
356
+ def __init__(self, session: AsyncSession) -> None:
357
+ self._session = session
358
+
359
+ async def add_hourly(self, rows: Sequence[HourlyUsageRollupRow]) -> None:
360
+ """Merge-add hourly rows: inserts new (bucket, dimensions) rows and
361
+ adds measures onto existing ones, in bounded statement chunks. Used
362
+ by lifecycle mirrors and tests; the fold pass itself writes via
363
+ DELETE-then-INSERT..SELECT."""
364
+ await _merge_add(
365
+ self._session,
366
+ RequestUsageHourlyRollup,
367
+ rows,
368
+ _HOURLY_KEY_COLUMNS,
369
+ _HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS,
370
+ HourlyUsageRollupRow,
371
+ )
372
+
373
+ async def add_errors(self, rows: Sequence[HourlyErrorRollupRow]) -> None:
374
+ await _merge_add(
375
+ self._session,
376
+ RequestUsageHourlyErrorRollup,
377
+ rows,
378
+ _ERROR_KEY_COLUMNS,
379
+ _ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS,
380
+ HourlyErrorRollupRow,
381
+ )
382
+
383
+ async def add_demand(self, rows: Sequence[QuarterDemandRollupRow]) -> None:
384
+ await _merge_add(
385
+ self._session,
386
+ RequestDemandQuarterRollup,
387
+ rows,
388
+ _QUARTER_KEY_COLUMNS,
389
+ _QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS,
390
+ QuarterDemandRollupRow,
391
+ )
392
+
393
+ async def add_conversations(self, rows: Sequence[ConversationHourlyRollupRow]) -> None:
394
+ await _merge_add(
395
+ self._session,
396
+ RequestConversationHourlyRollup,
397
+ rows,
398
+ _CONVERSATION_KEY_COLUMNS,
399
+ _CONVERSATION_KEY_COLUMNS + _CONVERSATION_MEASURE_COLUMNS,
400
+ ConversationHourlyRollupRow,
401
+ )
402
+
403
+ async def read_hourly(
404
+ self,
405
+ *,
406
+ since_epoch: int | None = None,
407
+ until_epoch: int | None = None,
408
+ filters: Sequence[ColumnElement[bool]] = (),
409
+ ) -> tuple[list[HourlyUsageRollupRow], datetime | None]:
410
+ return await self._read(
411
+ RequestUsageHourlyRollup,
412
+ RequestUsageHourlyRollup.bucket_epoch,
413
+ _HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS,
414
+ HourlyUsageRollupRow,
415
+ since_epoch,
416
+ until_epoch,
417
+ filters,
418
+ )
419
+
420
+ async def read_errors(
421
+ self,
422
+ *,
423
+ since_epoch: int | None = None,
424
+ until_epoch: int | None = None,
425
+ filters: Sequence[ColumnElement[bool]] = (),
426
+ ) -> tuple[list[HourlyErrorRollupRow], datetime | None]:
427
+ return await self._read(
428
+ RequestUsageHourlyErrorRollup,
429
+ RequestUsageHourlyErrorRollup.bucket_epoch,
430
+ _ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS,
431
+ HourlyErrorRollupRow,
432
+ since_epoch,
433
+ until_epoch,
434
+ filters,
435
+ )
436
+
437
+ async def read_demand(
438
+ self,
439
+ *,
440
+ since_epoch: int | None = None,
441
+ until_epoch: int | None = None,
442
+ filters: Sequence[ColumnElement[bool]] = (),
443
+ ) -> tuple[list[QuarterDemandRollupRow], datetime | None]:
444
+ return await self._read(
445
+ RequestDemandQuarterRollup,
446
+ RequestDemandQuarterRollup.slot_epoch,
447
+ _QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS,
448
+ QuarterDemandRollupRow,
449
+ since_epoch,
450
+ until_epoch,
451
+ filters,
452
+ )
453
+
454
+ async def _read(
455
+ self, model, epoch_column, columns: tuple[str, ...], row_type, since_epoch, until_epoch, filters=()
456
+ ):
457
+ join_conditions = list(filters)
458
+ if since_epoch is not None:
459
+ join_conditions.append(epoch_column >= since_epoch)
460
+ if until_epoch is not None:
461
+ join_conditions.append(epoch_column < until_epoch)
462
+ stmt = (
463
+ select(
464
+ AccountUsageRollupState.hourly_folded_through,
465
+ *(getattr(model, column) for column in columns),
466
+ )
467
+ .select_from(AccountUsageRollupState)
468
+ .outerjoin(model, and_(*join_conditions) if join_conditions else true())
469
+ .where(AccountUsageRollupState.id == _STATE_ROW_ID)
470
+ )
471
+ rows = (await self._session.execute(stmt)).all()
472
+ if not rows:
473
+ return [], None
474
+ watermark = rows[0][0]
475
+ return [row_type(*row[1:]) for row in rows if row[1] is not None], watermark
476
+
477
+
478
+ def epoch_seconds(value: datetime) -> int:
479
+ """Naive-UTC datetime to Unix epoch seconds (truncating sub-seconds)."""
480
+ return int((value - _EPOCH).total_seconds())
481
+
482
+
483
+ def floor_to_hour(value: datetime) -> datetime:
484
+ """Floor a naive-UTC datetime to its whole UTC hour."""
485
+ return _EPOCH + timedelta(seconds=(epoch_seconds(value) // HOURLY_BUCKET_SECONDS) * HOURLY_BUCKET_SECONDS)
486
+
487
+
488
+ def _requested_at_epoch_bucket_expr(session: AsyncSession, bucket_seconds: int) -> ColumnElement:
489
+ """Dialect-split bucket expression, identical arithmetic to the runtime
490
+ bucketing the read paths use (`RequestLogsRepository._bucket_epoch_expr`),
491
+ so folded buckets and legacy raw bucketing can never disagree."""
492
+ dialect = session.get_bind().dialect.name
493
+ if dialect == "postgresql":
494
+ return cast(
495
+ func.floor(func.extract("epoch", RequestLog.requested_at) / bucket_seconds) * bucket_seconds,
496
+ BigInteger,
497
+ )
498
+ epoch_col = cast(func.strftime("%s", RequestLog.requested_at), Integer)
499
+ return cast(epoch_col / bucket_seconds, Integer) * bucket_seconds
500
+
501
+
502
+ def _hourly_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
503
+ bucket = _requested_at_epoch_bucket_expr(session, HOURLY_BUCKET_SECONDS).label("bucket_epoch")
504
+ account_id = _dimension_expr(RequestLog.account_id).label("account_id")
505
+ api_key_id = _dimension_expr(RequestLog.api_key_id).label("api_key_id")
506
+ service_tier = _dimension_expr(RequestLog.service_tier).label("service_tier")
507
+ is_deleted = RequestLog.deleted_at.is_not(None).label("is_deleted")
508
+ output_or_reasoning = func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
509
+ # Exact mirror of the usage-summary reader's per-row clamp
510
+ # (`aggregate_usage_metrics_since` / `cached_input_tokens_from_log`):
511
+ # NULL cached counts 0, a NULL input keeps the (non-negative) cached
512
+ # value UNclamped, otherwise clamp to [0, input]. SQLite's two-argument
513
+ # min()/max() scalar functions are its least()/greatest().
514
+ dialect = session.get_bind().dialect.name
515
+ least = func.least if dialect == "postgresql" else func.min
516
+ greatest = func.greatest if dialect == "postgresql" else func.max
517
+ cached_clamped = case(
518
+ (RequestLog.cached_input_tokens.is_(None), 0),
519
+ (RequestLog.input_tokens.is_(None), greatest(0, RequestLog.cached_input_tokens)),
520
+ else_=greatest(0, least(RequestLog.cached_input_tokens, RequestLog.input_tokens)),
521
+ )
522
+ stmt = (
523
+ select(
524
+ bucket,
525
+ account_id,
526
+ api_key_id,
527
+ RequestLog.model,
528
+ service_tier,
529
+ RequestLog.request_kind,
530
+ is_deleted,
531
+ func.count(RequestLog.id),
532
+ func.coalesce(func.sum(case((RequestLog.status.not_in(NON_ERROR_STATUSES), 1), else_=0)), 0),
533
+ func.coalesce(func.sum(case((RequestLog.status == CANCELLED_STATUS, 1), else_=0)), 0),
534
+ func.coalesce(func.sum(RequestLog.input_tokens), 0),
535
+ func.coalesce(func.sum(RequestLog.output_tokens), 0),
536
+ func.coalesce(func.sum(RequestLog.reasoning_tokens), 0),
537
+ func.coalesce(func.sum(output_or_reasoning), 0),
538
+ func.coalesce(func.sum(RequestLog.cached_input_tokens), 0),
539
+ func.coalesce(func.sum(cached_clamped), 0),
540
+ func.coalesce(func.sum(RequestLog.cost_usd), 0.0),
541
+ func.coalesce(func.sum(case((RequestLog.cost_usd.is_not(None), 1), else_=0)), 0),
542
+ )
543
+ .where(*window)
544
+ .group_by(bucket, account_id, api_key_id, RequestLog.model, service_tier, RequestLog.request_kind, is_deleted)
545
+ )
546
+ return insert(RequestUsageHourlyRollup).from_select(list(_HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS), stmt)
547
+
548
+
549
+ def _error_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
550
+ bucket = _requested_at_epoch_bucket_expr(session, HOURLY_BUCKET_SECONDS).label("bucket_epoch")
551
+ account_id = _dimension_expr(RequestLog.account_id).label("account_id")
552
+ # Exact reproduction of the top-error read filter: warmup kinds excluded,
553
+ # soft-deleted rows INCLUDED, cancelled terminals excluded (they are not
554
+ # errors; buckets folded before that exclusion still carry
555
+ # client_disconnected counts, which the reads drop by error_code).
556
+ stmt = (
557
+ select(bucket, account_id, RequestLog.error_code, func.count(RequestLog.id))
558
+ .where(
559
+ *window,
560
+ RequestLog.request_kind.not_in(_EXCLUDED_REQUEST_KINDS),
561
+ RequestLog.status.not_in(NON_ERROR_STATUSES),
562
+ RequestLog.error_code.is_not(None),
563
+ )
564
+ .group_by(bucket, account_id, RequestLog.error_code)
565
+ )
566
+ return insert(RequestUsageHourlyErrorRollup).from_select(list(_ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS), stmt)
567
+
568
+
569
+ def _demand_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
570
+ # Full legacy demand grain (slot, account, api_key, model,
571
+ # reasoning_effort, kind, status): the planner's `_bin_demand_units`
572
+ # takes max(token, cost, request units) PER BIN before summing, so
573
+ # folding to a coarser grain would shrink forecasts wherever one slot
574
+ # mixes groups with different dominant components.
575
+ slot = _requested_at_epoch_bucket_expr(session, QUARTER_SLOT_SECONDS).label("slot_epoch")
576
+ account_id = _dimension_expr(RequestLog.account_id).label("account_id")
577
+ api_key_id = _dimension_expr(RequestLog.api_key_id).label("api_key_id")
578
+ reasoning_effort = _dimension_expr(RequestLog.reasoning_effort).label("reasoning_effort")
579
+ is_deleted = RequestLog.deleted_at.is_not(None).label("is_deleted")
580
+ output_or_reasoning = func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
581
+ stmt = (
582
+ select(
583
+ slot,
584
+ account_id,
585
+ api_key_id,
586
+ RequestLog.model,
587
+ reasoning_effort,
588
+ RequestLog.request_kind,
589
+ RequestLog.status,
590
+ is_deleted,
591
+ func.count(RequestLog.id),
592
+ func.coalesce(func.sum(RequestLog.input_tokens), 0),
593
+ func.coalesce(func.sum(output_or_reasoning), 0),
594
+ func.coalesce(func.sum(RequestLog.cached_input_tokens), 0),
595
+ func.coalesce(func.sum(RequestLog.cost_usd), 0.0),
596
+ )
597
+ .where(*window)
598
+ .group_by(
599
+ slot,
600
+ account_id,
601
+ api_key_id,
602
+ RequestLog.model,
603
+ reasoning_effort,
604
+ RequestLog.request_kind,
605
+ RequestLog.status,
606
+ is_deleted,
607
+ )
608
+ )
609
+ return insert(RequestDemandQuarterRollup).from_select(list(_QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS), stmt)
610
+
611
+
612
+ # Shared fold filter of the conversation satellite: exactly the row set every
613
+ # conversation reader counts. The dashboard readers additionally require
614
+ # `deleted_at IS NULL` and the reports readers additionally exclude
615
+ # `source = 'limit_warmup'`; the former is the `is_deleted` dimension, the
616
+ # latter is subsumed by the request_kind filter for every row the system
617
+ # writes (limit-warmup traffic always carries a warmup request_kind).
618
+ def _conversation_fold_conditions() -> tuple[ColumnElement[bool], ...]:
619
+ return (
620
+ conversation_id_expr().is_not(None),
621
+ RequestLog.request_kind.not_in(_EXCLUDED_REQUEST_KINDS),
622
+ )
623
+
624
+
625
+ def _conversation_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
626
+ bucket = _requested_at_epoch_bucket_expr(session, HOURLY_BUCKET_SECONDS).label("bucket_epoch")
627
+ conversation_id = conversation_id_expr().label("conversation_id")
628
+ account_id = _dimension_expr(RequestLog.account_id).label("account_id")
629
+ is_deleted = RequestLog.deleted_at.is_not(None).label("is_deleted")
630
+ stmt = (
631
+ select(bucket, conversation_id, account_id, is_deleted, func.count(RequestLog.id))
632
+ .where(*window, *_conversation_fold_conditions())
633
+ .group_by(bucket, conversation_id, account_id, is_deleted)
634
+ )
635
+ return insert(RequestConversationHourlyRollup).from_select(
636
+ list(_CONVERSATION_KEY_COLUMNS + _CONVERSATION_MEASURE_COLUMNS), stmt
637
+ )
638
+
639
+
640
+ async def run_hourly_fold_pass(*, now: datetime | None = None) -> int:
641
+ """Advance the hourly watermark toward `floor_hour(now - FOLD_LAG)`.
642
+
643
+ Bounded slices, each committed in its own transaction; at most
644
+ `TS_MAX_SLICES_PER_PASS` slices per pass, so the initial backfill resumes
645
+ across scheduler ticks instead of monopolizing one. Returns the number of
646
+ committed slices. Crash-safe: the defensive DELETE, the three
647
+ INSERT..SELECTs and the watermark advance commit atomically, so a crash
648
+ rolls the whole slice back and the retry recomputes it from scratch —
649
+ re-folding always converges to the same values (no add-fold double
650
+ counting is possible).
651
+
652
+ The first pass of each process additionally repairs the legacy-suspect
653
+ range `[upgrade_repair_from, watermark)` — buckets a legacy replica may
654
+ have folded after the cancelled_count migration during a rolling
655
+ upgrade — falling back to the trailing `UPGRADE_REPAIR_WINDOW` as
656
+ flip-flop defense once the marker is cleared (see the constant's note).
657
+ An incomplete (pass-bounded) repair re-arms itself for the next pass.
658
+ """
659
+ global _upgrade_repair_done
660
+ if not _upgrade_repair_done:
661
+ _upgrade_repair_done = await _run_upgrade_repair()
662
+ target = floor_to_hour((now or utcnow()) - FOLD_LAG)
663
+ committed = 0
664
+ while committed < TS_MAX_SLICES_PER_PASS:
665
+ async with get_background_session() as session:
666
+ status, wrote = await _fold_next_hourly_slice(session, target)
667
+ if wrote:
668
+ committed += 1
669
+ if status is _FoldStatus.DONE:
670
+ break
671
+ return committed
672
+
673
+
674
+ def _ceil_to_hour(value: datetime) -> datetime:
675
+ floored = floor_to_hour(value)
676
+ return floored if floored == value else floored + timedelta(hours=1)
677
+
678
+
679
+ async def _run_upgrade_repair() -> bool:
680
+ """Drive the upgrade repair in pass-bounded chunks; True when complete.
681
+
682
+ Same chunk/transaction discipline as the fold backfill: at most
683
+ `TS_MAX_SLICES_PER_PASS` chunks of `TS_FOLD_SLICE` per pass, each in its
684
+ own transaction, so repairing a legacy-folded backlog never monopolizes
685
+ a scheduler tick. An incomplete repair returns False and resumes from
686
+ the persisted marker on the next pass.
687
+ """
688
+ for _ in range(TS_MAX_SLICES_PER_PASS):
689
+ async with get_background_session() as session:
690
+ if await _repair_next_upgrade_chunk(session):
691
+ return True
692
+ return False
693
+
694
+
695
+ async def _repair_next_upgrade_chunk(session: AsyncSession) -> bool:
696
+ """Refold the next chunk of the legacy-suspect range; True when none left.
697
+
698
+ The suspect range is `[upgrade_repair_from, watermark)` while the marker
699
+ is set (stamped by the migration, or the epoch server default for a
700
+ state row an old replica bootstrapped after it), and the trailing
701
+ `UPGRADE_REPAIR_WINDOW` flip-flop defense once it is NULL. Both are
702
+ clamped to `ceil_hour(earliest surviving raw row)` — an unfiltered min:
703
+ retention's oldest-first contiguous frontier guarantees every row
704
+ (soft-deleted included) at or above it survives, so the repair never
705
+ deletes folded statistics it cannot recompute from raw.
706
+
707
+ Runs under the fold-state row lock (the caller already holds the leader
708
+ gate), so it can never interleave with a fold slice or lifecycle mirror,
709
+ and never moves the watermark. The same DELETE-then-INSERT statements as
710
+ the fold make every chunk converge on any input state — repairing a
711
+ legacy-folded bucket, re-running after a crash (the marker advances only
712
+ with the chunk's commit), or recomputing an already-correct window are
713
+ the same recomputation. The conversation satellite is untouched: its
714
+ fold never involved status.
715
+ """
716
+ async with sqlite_writer_section():
717
+ state = await _locked_state(session)
718
+ if state is None:
719
+ # Nothing folded yet, so nothing a legacy writer could have left.
720
+ return True
721
+ watermark = state.hourly_folded_through
722
+ marker = state.upgrade_repair_from
723
+ suspect_from = marker if marker is not None else watermark - UPGRADE_REPAIR_WINDOW
724
+ earliest_raw = (await session.execute(select(func.min(RequestLog.requested_at)))).scalar_one_or_none()
725
+ start = watermark if earliest_raw is None else max(suspect_from, _ceil_to_hour(earliest_raw))
726
+ if start >= watermark:
727
+ if marker is not None:
728
+ await _set_upgrade_repair_marker(session, None)
729
+ await session.commit()
730
+ return True
731
+ chunk_end = min(start + TS_FOLD_SLICE, watermark)
732
+ start_epoch, end_epoch = epoch_seconds(start), epoch_seconds(chunk_end)
733
+ await session.execute(
734
+ delete(RequestUsageHourlyRollup).where(
735
+ RequestUsageHourlyRollup.bucket_epoch >= start_epoch,
736
+ RequestUsageHourlyRollup.bucket_epoch < end_epoch,
737
+ )
738
+ )
739
+ await session.execute(
740
+ delete(RequestUsageHourlyErrorRollup).where(
741
+ RequestUsageHourlyErrorRollup.bucket_epoch >= start_epoch,
742
+ RequestUsageHourlyErrorRollup.bucket_epoch < end_epoch,
743
+ )
744
+ )
745
+ await session.execute(
746
+ delete(RequestDemandQuarterRollup).where(
747
+ RequestDemandQuarterRollup.slot_epoch >= start_epoch,
748
+ RequestDemandQuarterRollup.slot_epoch < end_epoch,
749
+ )
750
+ )
751
+ window = (RequestLog.requested_at >= start, RequestLog.requested_at < chunk_end)
752
+ await session.execute(_hourly_fold_insert(session, window))
753
+ await session.execute(_error_fold_insert(session, window))
754
+ await session.execute(_demand_fold_insert(session, window))
755
+ done = chunk_end >= watermark
756
+ if marker is not None:
757
+ await _set_upgrade_repair_marker(session, None if done else chunk_end)
758
+ await session.commit()
759
+ logger.info(
760
+ "Refolded hourly usage rollups in [%s, %s) (post-upgrade repair)",
761
+ start.isoformat(),
762
+ chunk_end.isoformat(),
763
+ )
764
+ return done
765
+
766
+
767
+ async def _set_upgrade_repair_marker(session: AsyncSession, value: datetime | None) -> None:
768
+ await session.execute(
769
+ update(AccountUsageRollupState)
770
+ .where(AccountUsageRollupState.id == _STATE_ROW_ID)
771
+ .values(upgrade_repair_from=value)
772
+ )
773
+
774
+
775
+ async def _fold_next_hourly_slice(session: AsyncSession, target: datetime) -> tuple[_FoldStatus, bool]:
776
+ async with sqlite_writer_section():
777
+ # Same state row (id=1) as the lifetime fold: one FOR UPDATE row lock
778
+ # serializes concurrent hourly passes, lifetime passes, and lifecycle
779
+ # mirrors. Re-read the watermark AFTER taking the lock — a concurrent
780
+ # pass may have advanced it while we waited.
781
+ state = await _locked_state(session)
782
+ if state is None:
783
+ await session.execute(_state_bootstrap_stmt(session))
784
+ await session.commit()
785
+ state = await _locked_state(session)
786
+ if state is None:
787
+ logger.warning("account_usage_rollup_state row missing; skipping hourly fold pass")
788
+ return _FoldStatus.DONE, False
789
+ watermark = state.hourly_folded_through
790
+ if watermark >= target:
791
+ return _FoldStatus.DONE, False
792
+
793
+ # Next populated instant in [watermark, target): jumps the empty
794
+ # prefix on first backfill and any mid-history gap on later slices,
795
+ # and guarantees the slice's first hour actually holds rows. Every
796
+ # row counts for at least one of the three aggregates (the hourly
797
+ # fold has no filter), so no filtered variant is needed.
798
+ next_populated = (
799
+ await session.execute(
800
+ select(func.min(RequestLog.requested_at)).where(
801
+ RequestLog.requested_at >= watermark,
802
+ RequestLog.requested_at < target,
803
+ )
804
+ )
805
+ ).scalar_one_or_none()
806
+ if next_populated is None:
807
+ # Nothing left below the target. Advancing (rather than leaving
808
+ # the watermark behind) keeps the readers' raw-tail window and
809
+ # the retention min-gate current; FOLD_LAG guarantees no insert
810
+ # can land below `now - FOLD_LAG`, so nothing can appear behind
811
+ # the advanced watermark later.
812
+ await _advance_hourly_watermark(session, target)
813
+ await session.commit()
814
+ logger.info("Folded hourly usage rollups through %s (no rows below target)", target.isoformat())
815
+ return _FoldStatus.DONE, True
816
+
817
+ start = max(watermark, floor_to_hour(next_populated))
818
+ slice_end = min(start + TS_FOLD_SLICE, target)
819
+ start_epoch, end_epoch = epoch_seconds(start), epoch_seconds(slice_end)
820
+
821
+ # Defensive DELETE: zero rows on the normal path (the watermark only
822
+ # moves forward), but makes an operator watermark reset (escape
823
+ # hatch) converge — a re-fold can never double-count or leave rows
824
+ # from a previous fold generation behind. Convergence is guaranteed
825
+ # under the escape hatch's documented precondition (raw below the
826
+ # target still present, or the reset truncated the rollups in the
827
+ # same transaction): every previously-folded hour then either has
828
+ # raw rows (re-covered by a slice window and its DELETE) or no
829
+ # rollup rows. Hours the min()-jump skips are deliberately NOT
830
+ # cleared — after a rewind-only reset over retention-pruned history
831
+ # (the documented forbidden state) the skipped rollup rows are the
832
+ # ONLY surviving copy of those statistics, and deleting them would
833
+ # turn an operator mistake into permanent data loss.
834
+ await session.execute(
835
+ delete(RequestUsageHourlyRollup).where(
836
+ RequestUsageHourlyRollup.bucket_epoch >= start_epoch,
837
+ RequestUsageHourlyRollup.bucket_epoch < end_epoch,
838
+ )
839
+ )
840
+ await session.execute(
841
+ delete(RequestUsageHourlyErrorRollup).where(
842
+ RequestUsageHourlyErrorRollup.bucket_epoch >= start_epoch,
843
+ RequestUsageHourlyErrorRollup.bucket_epoch < end_epoch,
844
+ )
845
+ )
846
+ await session.execute(
847
+ delete(RequestDemandQuarterRollup).where(
848
+ RequestDemandQuarterRollup.slot_epoch >= start_epoch,
849
+ RequestDemandQuarterRollup.slot_epoch < end_epoch,
850
+ )
851
+ )
852
+
853
+ # Half-open [start, slice_end): hour-aligned bounds, so a display
854
+ # bucket is never split between the folded side and the raw tail.
855
+ window = (RequestLog.requested_at >= start, RequestLog.requested_at < slice_end)
856
+ await session.execute(_hourly_fold_insert(session, window))
857
+ await session.execute(_error_fold_insert(session, window))
858
+ await session.execute(_demand_fold_insert(session, window))
859
+ await _advance_hourly_watermark(session, slice_end)
860
+ await session.commit()
861
+ logger.info("Folded hourly usage rollups through %s", slice_end.isoformat())
862
+ return (_FoldStatus.DONE if slice_end >= target else _FoldStatus.CONTINUE), True
863
+
864
+
865
+ async def _advance_hourly_watermark(session: AsyncSession, value: datetime) -> None:
866
+ await session.execute(
867
+ update(AccountUsageRollupState)
868
+ .where(AccountUsageRollupState.id == _STATE_ROW_ID)
869
+ .values(hourly_folded_through=value)
870
+ )
871
+
872
+
873
+ async def run_conversation_fold_pass(*, now: datetime | None = None) -> int:
874
+ """Advance the conversation watermark toward `floor_hour(now - FOLD_LAG)`.
875
+
876
+ Same slice/transaction/crash-safety contract as `run_hourly_fold_pass`
877
+ (DELETE-then-INSERT slices, bounded per pass, watermark advance committed
878
+ atomically with the slice), on the satellite's own watermark so its
879
+ from-epoch backfill neither rewinds nor stalls the other rollups.
880
+ """
881
+ target = floor_to_hour((now or utcnow()) - FOLD_LAG)
882
+ committed = 0
883
+ while committed < TS_MAX_SLICES_PER_PASS:
884
+ async with get_background_session() as session:
885
+ status, wrote = await _fold_next_conversation_slice(session, target)
886
+ if wrote:
887
+ committed += 1
888
+ if status is _FoldStatus.DONE:
889
+ break
890
+ return committed
891
+
892
+
893
+ async def _fold_next_conversation_slice(session: AsyncSession, target: datetime) -> tuple[_FoldStatus, bool]:
894
+ async with sqlite_writer_section():
895
+ # Same state row lock as every other fold and lifecycle mirror.
896
+ state = await _locked_state(session)
897
+ if state is None:
898
+ await session.execute(_state_bootstrap_stmt(session))
899
+ await session.commit()
900
+ state = await _locked_state(session)
901
+ if state is None:
902
+ logger.warning("account_usage_rollup_state row missing; skipping conversation fold pass")
903
+ return _FoldStatus.DONE, False
904
+ watermark = state.conversation_folded_through
905
+ if watermark >= target:
906
+ return _FoldStatus.DONE, False
907
+
908
+ # Unlike the hourly fold (which folds every row), only rows matching
909
+ # the fold filter contribute here, so the empty-prefix/gap jump scans
910
+ # for the next COUNTABLE row — hours holding only conversation-less
911
+ # rows are skipped exactly like empty ones.
912
+ next_populated = (
913
+ await session.execute(
914
+ select(func.min(RequestLog.requested_at)).where(
915
+ RequestLog.requested_at >= watermark,
916
+ RequestLog.requested_at < target,
917
+ *_conversation_fold_conditions(),
918
+ )
919
+ )
920
+ ).scalar_one_or_none()
921
+ if next_populated is None:
922
+ await _advance_conversation_watermark(session, target)
923
+ await session.commit()
924
+ logger.info("Folded conversation rollups through %s (no rows below target)", target.isoformat())
925
+ return _FoldStatus.DONE, True
926
+
927
+ start = max(watermark, floor_to_hour(next_populated))
928
+ slice_end = min(start + TS_FOLD_SLICE, target)
929
+ start_epoch, end_epoch = epoch_seconds(start), epoch_seconds(slice_end)
930
+
931
+ # Defensive DELETE with the same convergence/preservation trade-off
932
+ # as the hourly slice: skipped hours are deliberately NOT cleared.
933
+ await session.execute(
934
+ delete(RequestConversationHourlyRollup).where(
935
+ RequestConversationHourlyRollup.bucket_epoch >= start_epoch,
936
+ RequestConversationHourlyRollup.bucket_epoch < end_epoch,
937
+ )
938
+ )
939
+ window = (RequestLog.requested_at >= start, RequestLog.requested_at < slice_end)
940
+ await session.execute(_conversation_fold_insert(session, window))
941
+ await _advance_conversation_watermark(session, slice_end)
942
+ await session.commit()
943
+ logger.info("Folded conversation rollups through %s", slice_end.isoformat())
944
+ return (_FoldStatus.DONE if slice_end >= target else _FoldStatus.CONTINUE), True
945
+
946
+
947
+ async def _advance_conversation_watermark(session: AsyncSession, value: datetime) -> None:
948
+ await session.execute(
949
+ update(AccountUsageRollupState)
950
+ .where(AccountUsageRollupState.id == _STATE_ROW_ID)
951
+ .values(conversation_folded_through=value)
952
+ )
953
+
954
+
955
+ # --- Account lifecycle mirrors -------------------------------------------
956
+ #
957
+ # The ONLY code paths allowed to touch folded buckets after the watermark
958
+ # passed them. They mirror the raw request_logs mutation exactly (a dimension
959
+ # move, never a recompute — raw below the watermark may already be pruned).
960
+ # Callers MUST hold the fold-state lock (`lock_fold_state`) in the same
961
+ # transaction, so a mirror can never interleave with an in-flight fold slice.
962
+
963
+ _ROLLUP_TABLES = (
964
+ (RequestUsageHourlyRollup, _HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS, HourlyUsageRollupRow, "add_hourly"),
965
+ (RequestUsageHourlyErrorRollup, _ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS, HourlyErrorRollupRow, "add_errors"),
966
+ (RequestDemandQuarterRollup, _QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS, QuarterDemandRollupRow, "add_demand"),
967
+ (
968
+ RequestConversationHourlyRollup,
969
+ _CONVERSATION_KEY_COLUMNS + _CONVERSATION_MEASURE_COLUMNS,
970
+ ConversationHourlyRollupRow,
971
+ "add_conversations",
972
+ ),
973
+ )
974
+
975
+
976
+ async def _rekey_account_rows(session: AsyncSession, account_ids: list[str], rekey) -> None:
977
+ repo = RequestUsageTimeRollupRepository(session)
978
+ stored_ids = [to_dimension(account_id) for account_id in account_ids]
979
+ for model, columns, row_type, adder in _ROLLUP_TABLES:
980
+ stmt = select(*(getattr(model, column) for column in columns)).where(model.account_id.in_(stored_ids))
981
+ rows = [row_type(*row) for row in (await session.execute(stmt)).all()]
982
+ if not rows:
983
+ continue
984
+ await session.execute(delete(model).where(model.account_id.in_(stored_ids)))
985
+ await getattr(repo, adder)([rekey(row) for row in rows])
986
+
987
+
988
+ async def mirror_account_soft_delete_into_time_rollups(session: AsyncSession, account_id: str) -> None:
989
+ """Mirror `AccountsRepository.delete()`'s soft path, which retroactively
990
+ detaches the account's ENTIRE raw history (`account_id=NULL,
991
+ deleted_at=now`): folded buckets move to the `(NULL-sentinel,
992
+ is_deleted=true)` dimension (merge-added — an orphaned-deleted bucket
993
+ may already exist).
994
+ The error satellite has no `is_deleted` dimension (its read includes
995
+ soft-deleted rows), so only `account_id` is re-keyed there. The
996
+ conversation satellite carries `account_id` precisely so this mirror can
997
+ re-attribute presence the way the raw UPDATE does — the dashboard
998
+ conversation reads (which exclude soft-deleted rows) then stop counting
999
+ it while the reports reads (which include them) keep it.
1000
+ """
1001
+
1002
+ def _rekey(row):
1003
+ if isinstance(row, HourlyErrorRollupRow):
1004
+ return replace(row, account_id=DIMENSION_SENTINEL)
1005
+ return replace(row, account_id=DIMENSION_SENTINEL, is_deleted=True)
1006
+
1007
+ await _rekey_account_rows(session, [account_id], _rekey)
1008
+ from app.modules.reports.rollup import rekey_report_accounts
1009
+
1010
+ await rekey_report_accounts(session, [account_id], None)
1011
+
1012
+
1013
+ async def mirror_account_hard_delete_into_time_rollups(session: AsyncSession, account_id: str) -> None:
1014
+ """Mirror the history-deleting path (raw rows physically removed)."""
1015
+ from app.db.models import RequestReportHourlyRollup
1016
+
1017
+ await session.execute(
1018
+ delete(RequestReportHourlyRollup).where(RequestReportHourlyRollup.account_id == to_dimension(account_id))
1019
+ )
1020
+ for model, *_rest in _ROLLUP_TABLES:
1021
+ await session.execute(delete(model).where(model.account_id == to_dimension(account_id)))
1022
+
1023
+
1024
+ async def merge_time_rollups_into(session: AsyncSession, canonical_account_id: str, duplicate_ids: list[str]) -> None:
1025
+ """Mirror duplicate-account consolidation, which reassigns the
1026
+ duplicates' raw logs to the canonical account: folded buckets follow
1027
+ bucket-wise (merge-add onto the canonical dimension, then the duplicate
1028
+ rows are removed). Must run in the consolidation transaction, under the
1029
+ fold-state lock the caller already holds.
1030
+ """
1031
+ if not duplicate_ids:
1032
+ return
1033
+ from app.modules.reports.rollup import rekey_report_accounts
1034
+
1035
+ await rekey_report_accounts(session, duplicate_ids, canonical_account_id)
1036
+ canonical_dimension = to_dimension(canonical_account_id)
1037
+ await _rekey_account_rows(session, duplicate_ids, lambda row: replace(row, account_id=canonical_dimension))