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
app/db/migrate.py ADDED
@@ -0,0 +1,1249 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import re
6
+ import time
7
+ import warnings
8
+ from collections.abc import Sequence
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Iterator, cast
13
+
14
+ from alembic import command
15
+ from alembic.autogenerate import compare_metadata
16
+ from alembic.config import Config
17
+ from alembic.migration import MigrationContext
18
+ from alembic.script import ScriptDirectory
19
+ from alembic.script.revision import RevisionError
20
+ from alembic.util.exc import CommandError
21
+ from anyio import to_thread
22
+ from sqlalchemy import create_engine, inspect, text
23
+ from sqlalchemy import exc as sa_exc
24
+ from sqlalchemy.engine import Connection
25
+
26
+ from app.core.config.settings import get_settings
27
+ from app.db.alembic.revision_ids import LEGACY_MIGRATION_TO_NEW_REVISION, OLD_TO_NEW_REVISION_MAP, REVISION_ID_PATTERN
28
+ from app.db.migration_lock import migration_lock
29
+ from app.db.migration_url import to_sync_database_url
30
+ from app.db.models import Base
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _ALEMBIC_VERSION_TABLE = "alembic_version"
35
+ _ALEMBIC_VERSION_COLUMN = "version_num"
36
+ _LEGACY_MIGRATIONS_TABLE = "schema_migrations"
37
+ _RUNTIME_SENTINELS_TABLE = "runtime_sentinels"
38
+ _REQUIRED_TABLES_FOR_LEGACY_STAMP = frozenset(
39
+ {
40
+ "accounts",
41
+ "usage_history",
42
+ "request_logs",
43
+ "sticky_sessions",
44
+ "dashboard_settings",
45
+ }
46
+ )
47
+
48
+ LEGACY_MIGRATION_ORDER: tuple[str, ...] = (
49
+ "001_normalize_account_plan_types",
50
+ "002_add_request_logs_reasoning_effort",
51
+ "003_add_accounts_reset_at",
52
+ "004_add_accounts_chatgpt_account_id",
53
+ "005_add_dashboard_settings",
54
+ "006_add_dashboard_settings_totp",
55
+ "007_add_dashboard_settings_password",
56
+ "008_add_api_keys",
57
+ "009_add_api_key_limits",
58
+ "010_add_idx_logs_requested_at",
59
+ )
60
+
61
+ LEGACY_TO_REVISION: dict[str, str] = {
62
+ migration_name: LEGACY_MIGRATION_TO_NEW_REVISION[migration_name] for migration_name in LEGACY_MIGRATION_ORDER
63
+ }
64
+
65
+ _BRANCHED_ENFORCEMENT_LEGACY_REVISION = "013_add_api_key_enforcement_fields"
66
+ _BRANCHED_ENFORCEMENT_REPAIR_ANCESTOR = OLD_TO_NEW_REVISION_MAP[_BRANCHED_ENFORCEMENT_LEGACY_REVISION]
67
+ _BRANCHED_ENFORCEMENT_DESCENDANT_REVISIONS = frozenset(
68
+ {
69
+ "20260225_000000_add_dashboard_settings_routing_strategy",
70
+ "20260228_020000_align_api_key_limit_enum_types",
71
+ "20260228_030000_add_api_firewall_allowlist",
72
+ "20260307_000000_add_api_key_enforcement_fields",
73
+ }
74
+ )
75
+ _MANUAL_DRIFT_INDEX_REQUIREMENTS: dict[str, frozenset[str]] = {
76
+ "usage_history": frozenset(
77
+ {
78
+ "idx_usage_window_account_latest",
79
+ "idx_usage_window_account_time",
80
+ "idx_usage_window_raw_account_latest",
81
+ "idx_usage_window_account_time_covering",
82
+ "idx_usage_window_raw_account_time_covering",
83
+ }
84
+ ),
85
+ "request_logs": frozenset(
86
+ {
87
+ "idx_logs_requested_at_id",
88
+ "idx_logs_deleted_at_requested_at_id",
89
+ "idx_logs_requested_at_model_tier",
90
+ "idx_logs_model_effort_time",
91
+ "idx_logs_status_error_time",
92
+ "idx_logs_source_requested_at",
93
+ "idx_logs_dash_usage_covering",
94
+ "idx_logs_missing_cost",
95
+ "idx_logs_live_api_key",
96
+ "idx_logs_live_model_effort",
97
+ "idx_logs_live_status_error",
98
+ }
99
+ ),
100
+ "additional_usage_history": frozenset(
101
+ {
102
+ "ix_additional_usage_distinct_labels",
103
+ "ix_additional_usage_alias_limit_latest",
104
+ "ix_additional_usage_alias_feature_latest",
105
+ }
106
+ ),
107
+ "account_limit_warmups": frozenset(
108
+ {
109
+ "idx_account_limit_warmups_account_attempted",
110
+ "idx_account_limit_warmups_status_attempted",
111
+ }
112
+ ),
113
+ "api_keys": frozenset({"idx_api_keys_name"}),
114
+ }
115
+ _SQLITE_FLOAT_TYPE_COMPAT_COLUMNS = frozenset(
116
+ {
117
+ ("dashboard_settings", "sticky_reallocation_primary_budget_threshold_pct"),
118
+ ("dashboard_settings", "sticky_reallocation_secondary_budget_threshold_pct"),
119
+ }
120
+ )
121
+ # Columns the ORM no longer maps but the schema still carries. A replica running
122
+ # the previous release keeps mapping them and renders explicit NULLs in its
123
+ # request-log INSERTs, so the physical drop must wait one release after the
124
+ # mapping retirement (the Helm migration Job runs before old replicas drain).
125
+ _LEGACY_EXTRA_COLUMNS = frozenset(
126
+ {
127
+ ("request_logs", "slim_summary_json"),
128
+ # Retired from the ORM in retire-prewarm-canary-column-mappings; drop
129
+ # revision + removal from this set is queued for the following release.
130
+ ("request_logs", "prewarm_canary_bucket"),
131
+ ("request_logs", "prewarm_eligible_reason"),
132
+ }
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class LegacyBootstrapResult:
138
+ stamped_revision: str | None
139
+ legacy_row_count: int
140
+ unknown_migrations: tuple[str, ...]
141
+ had_non_contiguous_entries: bool
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class MigrationRunResult:
146
+ current_revision: str | None
147
+ bootstrap: LegacyBootstrapResult
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class MigrationState:
152
+ current_revision: str | None
153
+ head_revision: str
154
+ has_alembic_version_table: bool
155
+ has_legacy_migrations_table: bool
156
+ needs_upgrade: bool
157
+ unknown_revisions: tuple[str, ...]
158
+ is_ahead: bool
159
+
160
+
161
+ class MigrationBootstrapError(RuntimeError):
162
+ pass
163
+
164
+
165
+ def _script_location() -> str:
166
+ return str((Path(__file__).resolve().parent / "alembic").resolve())
167
+
168
+
169
+ def _build_alembic_config(database_url: str) -> Config:
170
+ config = Config()
171
+ config.set_main_option("script_location", _script_location())
172
+ sync_database_url = to_sync_database_url(database_url)
173
+ # `to_sync_database_url` routes the URL through `make_url().render_as_string()`,
174
+ # which percent-encodes the path on Windows (e.g.
175
+ # `sqlite:///C%3A%5CUsers%5C...%5Cstore.db`). Alembic stores option values in
176
+ # a `configparser` using `BasicInterpolation`, which treats a bare `%` as
177
+ # interpolation syntax and raises `ValueError: invalid interpolation syntax`
178
+ # from `set_main_option`. Escape `%` -> `%%` so ConfigParser keeps the value
179
+ # verbatim; `get_main_option` decodes `%%` back to `%`, so the URL handed to
180
+ # SQLAlchemy is unchanged.
181
+ config.set_main_option("sqlalchemy.url", sync_database_url.replace("%", "%%"))
182
+ config.attributes["configure_logger"] = False
183
+ return config
184
+
185
+
186
+ def _required_sqlalchemy_url(config: Config) -> str:
187
+ sync_database_url = config.get_main_option("sqlalchemy.url")
188
+ if not sync_database_url:
189
+ raise MigrationBootstrapError("sqlalchemy.url is missing in alembic config")
190
+ return sync_database_url
191
+
192
+
193
+ @contextmanager
194
+ def _sync_connection(sync_database_url: str) -> Iterator[Connection]:
195
+ engine = create_engine(sync_database_url, future=True)
196
+ try:
197
+ with engine.connect() as connection:
198
+ yield connection
199
+ finally:
200
+ engine.dispose()
201
+
202
+
203
+ @contextmanager
204
+ def _sync_transaction(sync_database_url: str) -> Iterator[Connection]:
205
+ engine = create_engine(sync_database_url, future=True)
206
+ try:
207
+ with engine.begin() as connection:
208
+ yield connection
209
+ finally:
210
+ engine.dispose()
211
+
212
+
213
+ def _read_table_names(connection: Connection) -> set[str]:
214
+ inspector = inspect(connection)
215
+ return set(inspector.get_table_names())
216
+
217
+
218
+ def _read_legacy_migration_names(connection: Connection) -> set[str]:
219
+ result = connection.execute(text(f"SELECT name FROM {_LEGACY_MIGRATIONS_TABLE}"))
220
+ names = {str(row[0]) for row in result.fetchall() if row and row[0] is not None}
221
+ return names
222
+
223
+
224
+ def _read_current_revisions_from_connection(connection: Connection) -> tuple[str, ...]:
225
+ try:
226
+ rows = connection.execute(text(f"SELECT {_ALEMBIC_VERSION_COLUMN} FROM {_ALEMBIC_VERSION_TABLE}")).fetchall()
227
+ except (sa_exc.ProgrammingError, sa_exc.OperationalError) as exc:
228
+ # PostgreSQL can still raise UndefinedTable here on a fresh database if
229
+ # the alembic_version table is absent when startup migration state is
230
+ # re-read. SQLite raises OperationalError for the same missing-table
231
+ # path. Treat both the same as "no revision yet".
232
+ message = str(exc).lower()
233
+ if _ALEMBIC_VERSION_TABLE in message and (
234
+ "does not exist" in message or "undefinedtable" in message or "no such table" in message
235
+ ):
236
+ return ()
237
+ raise
238
+ revisions = {str(row[0]) for row in rows if row and row[0]}
239
+ return tuple(sorted(revisions))
240
+
241
+
242
+ def _read_current_revision_from_connection(connection: Connection) -> str | None:
243
+ revisions = list(_read_current_revisions_from_connection(connection))
244
+ if not revisions:
245
+ return None
246
+ if len(revisions) == 1:
247
+ return revisions[0]
248
+ return ",".join(sorted(revisions))
249
+
250
+
251
+ def _contiguous_prefix_count(applied: set[str]) -> int:
252
+ contiguous = 0
253
+ for migration_name in LEGACY_MIGRATION_ORDER:
254
+ if migration_name in applied:
255
+ contiguous += 1
256
+ continue
257
+ break
258
+ return contiguous
259
+
260
+
261
+ def _detect_non_contiguous_entries(applied: set[str], contiguous_prefix_count: int) -> bool:
262
+ trailing = LEGACY_MIGRATION_ORDER[contiguous_prefix_count:]
263
+ return any(name in applied for name in trailing)
264
+
265
+
266
+ def _missing_required_legacy_tables_for_stamp(tables: set[str]) -> tuple[str, ...]:
267
+ return tuple(sorted(table for table in _REQUIRED_TABLES_FOR_LEGACY_STAMP if table not in tables))
268
+
269
+
270
+ def _bootstrap_legacy_history(config: Config) -> LegacyBootstrapResult:
271
+ sync_database_url = _required_sqlalchemy_url(config)
272
+
273
+ with _sync_connection(sync_database_url) as connection:
274
+ tables = _read_table_names(connection)
275
+ if _ALEMBIC_VERSION_TABLE in tables:
276
+ return LegacyBootstrapResult(
277
+ stamped_revision=None,
278
+ legacy_row_count=0,
279
+ unknown_migrations=(),
280
+ had_non_contiguous_entries=False,
281
+ )
282
+
283
+ if _LEGACY_MIGRATIONS_TABLE not in tables:
284
+ return LegacyBootstrapResult(
285
+ stamped_revision=None,
286
+ legacy_row_count=0,
287
+ unknown_migrations=(),
288
+ had_non_contiguous_entries=False,
289
+ )
290
+
291
+ applied = _read_legacy_migration_names(connection)
292
+
293
+ if not applied:
294
+ return LegacyBootstrapResult(
295
+ stamped_revision=None,
296
+ legacy_row_count=0,
297
+ unknown_migrations=(),
298
+ had_non_contiguous_entries=False,
299
+ )
300
+
301
+ unknown = tuple(sorted(name for name in applied if name not in LEGACY_TO_REVISION))
302
+ contiguous_count = _contiguous_prefix_count(applied)
303
+ has_non_contiguous = _detect_non_contiguous_entries(applied, contiguous_count)
304
+
305
+ if contiguous_count <= 0:
306
+ return LegacyBootstrapResult(
307
+ stamped_revision=None,
308
+ legacy_row_count=len(applied),
309
+ unknown_migrations=unknown,
310
+ had_non_contiguous_entries=has_non_contiguous,
311
+ )
312
+
313
+ missing_required_tables = _missing_required_legacy_tables_for_stamp(tables)
314
+ if missing_required_tables:
315
+ logger.warning(
316
+ "Skipping legacy bootstrap stamp due to missing required tables tables=%s",
317
+ missing_required_tables,
318
+ )
319
+ return LegacyBootstrapResult(
320
+ stamped_revision=None,
321
+ legacy_row_count=len(applied),
322
+ unknown_migrations=unknown,
323
+ had_non_contiguous_entries=has_non_contiguous,
324
+ )
325
+
326
+ target_legacy_name = LEGACY_MIGRATION_ORDER[contiguous_count - 1]
327
+ target_revision = LEGACY_TO_REVISION[target_legacy_name]
328
+ _ensure_alembic_version_table_capacity(config)
329
+ command.stamp(config, target_revision)
330
+
331
+ return LegacyBootstrapResult(
332
+ stamped_revision=target_revision,
333
+ legacy_row_count=len(applied),
334
+ unknown_migrations=unknown,
335
+ had_non_contiguous_entries=has_non_contiguous,
336
+ )
337
+
338
+
339
+ def _read_current_revision(sync_database_url: str) -> str | None:
340
+ with _sync_connection(sync_database_url) as connection:
341
+ tables = _read_table_names(connection)
342
+ if _ALEMBIC_VERSION_TABLE not in tables:
343
+ return None
344
+ return _read_current_revision_from_connection(connection)
345
+
346
+
347
+ def _head_revision(config: Config) -> str:
348
+ script_directory = ScriptDirectory.from_config(config)
349
+ heads = sorted(script_directory.get_heads())
350
+ if not heads:
351
+ raise MigrationBootstrapError("No Alembic head revision found")
352
+ if len(heads) == 1:
353
+ return heads[0]
354
+ return ",".join(heads)
355
+
356
+
357
+ def _known_revisions(config: Config) -> set[str]:
358
+ script_directory = ScriptDirectory.from_config(config)
359
+ return {revision.revision for revision in script_directory.walk_revisions() if revision.revision}
360
+
361
+
362
+ def _max_revision_id_length(config: Config) -> int:
363
+ script_directory = ScriptDirectory.from_config(config)
364
+ lengths = [len(revision.revision) for revision in script_directory.walk_revisions() if revision.revision]
365
+ if not lengths:
366
+ raise MigrationBootstrapError("No Alembic revisions found")
367
+ return max(lengths)
368
+
369
+
370
+ def _ensure_alembic_version_table_capacity_for_connection(connection: Connection, *, required_length: int) -> None:
371
+ if connection.dialect.name != "postgresql":
372
+ return
373
+
374
+ inspector = inspect(connection)
375
+ if not inspector.has_table(_ALEMBIC_VERSION_TABLE):
376
+ # IF NOT EXISTS is defense-in-depth against concurrent out-of-band
377
+ # `alembic upgrade` invocations that bypass run_upgrade's migration
378
+ # lock; the product paths are already serialized by migration_lock.
379
+ connection.execute(
380
+ text(
381
+ " ".join(
382
+ (
383
+ f"CREATE TABLE IF NOT EXISTS {_ALEMBIC_VERSION_TABLE} (",
384
+ f"{_ALEMBIC_VERSION_COLUMN} VARCHAR({required_length}) NOT NULL,",
385
+ f"PRIMARY KEY ({_ALEMBIC_VERSION_COLUMN})",
386
+ ")",
387
+ )
388
+ )
389
+ )
390
+ )
391
+ return
392
+
393
+ columns = inspector.get_columns(_ALEMBIC_VERSION_TABLE)
394
+ version_num_column = next((column for column in columns if column.get("name") == _ALEMBIC_VERSION_COLUMN), None)
395
+ if version_num_column is None:
396
+ raise MigrationBootstrapError(
397
+ f"{_ALEMBIC_VERSION_TABLE}.{_ALEMBIC_VERSION_COLUMN} is missing from migration metadata table"
398
+ )
399
+ version_num_type = version_num_column.get("type")
400
+ version_num_length = getattr(version_num_type, "length", None)
401
+ if version_num_length is None or version_num_length >= required_length:
402
+ return
403
+
404
+ connection.execute(
405
+ text(
406
+ f"ALTER TABLE {_ALEMBIC_VERSION_TABLE} "
407
+ f"ALTER COLUMN {_ALEMBIC_VERSION_COLUMN} TYPE VARCHAR({required_length})"
408
+ )
409
+ )
410
+
411
+
412
+ def _ensure_alembic_version_table_capacity(config: Config) -> None:
413
+ sync_database_url = _required_sqlalchemy_url(config)
414
+ required_length = _max_revision_id_length(config)
415
+ with _sync_transaction(sync_database_url) as connection:
416
+ _ensure_alembic_version_table_capacity_for_connection(connection, required_length=required_length)
417
+
418
+
419
+ def _collect_migration_policy_violations(config: Config) -> tuple[str, ...]:
420
+ violations: list[str] = []
421
+ script_directory = ScriptDirectory.from_config(config)
422
+
423
+ heads = sorted(script_directory.get_heads())
424
+ if len(heads) != 1:
425
+ violations.append(f"alembic_head_count_invalid expected=1 actual={len(heads)} heads={','.join(heads)}")
426
+
427
+ seen_revision_ids: set[str] = set()
428
+ for revision in script_directory.walk_revisions():
429
+ revision_id = revision.revision
430
+ if not revision_id:
431
+ continue
432
+
433
+ if revision_id in seen_revision_ids:
434
+ violations.append(f"alembic_revision_duplicate revision={revision_id}")
435
+ else:
436
+ seen_revision_ids.add(revision_id)
437
+
438
+ if not REVISION_ID_PATTERN.fullmatch(revision_id):
439
+ violations.append(f"alembic_revision_id_format_invalid revision={revision_id}")
440
+
441
+ revision_path = getattr(revision, "path", None)
442
+ if revision_path:
443
+ actual_name = Path(str(revision_path)).name
444
+ expected_name = f"{revision_id}.py"
445
+ if actual_name != expected_name:
446
+ violations.append(
447
+ "alembic_revision_filename_mismatch "
448
+ f"revision={revision_id} expected={expected_name} actual={actual_name}"
449
+ )
450
+
451
+ return tuple(sorted(violations))
452
+
453
+
454
+ def check_migration_policy(database_url: str) -> tuple[str, ...]:
455
+ config = _build_alembic_config(database_url)
456
+ return _collect_migration_policy_violations(config)
457
+
458
+
459
+ def _remap_legacy_alembic_revisions(config: Config) -> tuple[str, ...]:
460
+ sync_database_url = _required_sqlalchemy_url(config)
461
+ known_revisions = _known_revisions(config)
462
+
463
+ with _sync_transaction(sync_database_url) as connection:
464
+ tables = _read_table_names(connection)
465
+ if _ALEMBIC_VERSION_TABLE not in tables:
466
+ return ()
467
+
468
+ current_revisions = _read_current_revisions_from_connection(connection)
469
+ if not current_revisions:
470
+ return ()
471
+
472
+ unsupported = tuple(
473
+ sorted(
474
+ revision
475
+ for revision in current_revisions
476
+ if revision not in known_revisions and revision not in OLD_TO_NEW_REVISION_MAP
477
+ )
478
+ )
479
+ if unsupported:
480
+ raise MigrationBootstrapError(
481
+ "Unsupported alembic_version revision ids detected; manual intervention required "
482
+ f"unsupported={','.join(unsupported)}"
483
+ )
484
+
485
+ remapped_set = {OLD_TO_NEW_REVISION_MAP.get(revision, revision) for revision in current_revisions}
486
+
487
+ if (
488
+ _BRANCHED_ENFORCEMENT_LEGACY_REVISION in current_revisions
489
+ and _BRANCHED_ENFORCEMENT_REPAIR_ANCESTOR in remapped_set
490
+ and remapped_set & _BRANCHED_ENFORCEMENT_DESCENDANT_REVISIONS
491
+ ):
492
+ remapped_set.discard(_BRANCHED_ENFORCEMENT_REPAIR_ANCESTOR)
493
+
494
+ remapped = tuple(sorted(remapped_set))
495
+ if remapped == current_revisions:
496
+ return ()
497
+
498
+ connection.execute(text(f"DELETE FROM {_ALEMBIC_VERSION_TABLE}"))
499
+ for revision in remapped:
500
+ connection.execute(
501
+ text(
502
+ f"INSERT INTO {_ALEMBIC_VERSION_TABLE} ({_ALEMBIC_VERSION_COLUMN}) "
503
+ f"VALUES (:{_ALEMBIC_VERSION_COLUMN})"
504
+ ),
505
+ {_ALEMBIC_VERSION_COLUMN: revision},
506
+ )
507
+
508
+ logger.info(
509
+ "Remapped legacy alembic revision ids old=%s new=%s",
510
+ current_revisions,
511
+ remapped,
512
+ )
513
+ return current_revisions
514
+
515
+
516
+ def inspect_migration_state(database_url: str) -> MigrationState:
517
+ config = _build_alembic_config(database_url)
518
+ sync_database_url = _required_sqlalchemy_url(config)
519
+ head_revision = _head_revision(config)
520
+
521
+ with _sync_connection(sync_database_url) as connection:
522
+ tables = _read_table_names(connection)
523
+ has_alembic = _ALEMBIC_VERSION_TABLE in tables
524
+ has_legacy = _LEGACY_MIGRATIONS_TABLE in tables
525
+ current_revisions = _read_current_revisions_from_connection(connection) if has_alembic else ()
526
+
527
+ if not current_revisions:
528
+ current = None
529
+ elif len(current_revisions) == 1:
530
+ current = current_revisions[0]
531
+ else:
532
+ current = ",".join(current_revisions)
533
+
534
+ known_revisions = _known_revisions(config)
535
+ unknown_revisions = tuple(
536
+ sorted(
537
+ revision
538
+ for revision in current_revisions
539
+ if revision not in known_revisions and revision not in OLD_TO_NEW_REVISION_MAP
540
+ )
541
+ )
542
+
543
+ if has_alembic:
544
+ needs_upgrade = current != head_revision
545
+ else:
546
+ # Missing alembic_version always requires bootstrap and/or upgrade.
547
+ needs_upgrade = True
548
+
549
+ return MigrationState(
550
+ current_revision=current,
551
+ head_revision=head_revision,
552
+ has_alembic_version_table=has_alembic,
553
+ has_legacy_migrations_table=has_legacy,
554
+ needs_upgrade=needs_upgrade,
555
+ unknown_revisions=unknown_revisions,
556
+ is_ahead=bool(unknown_revisions),
557
+ )
558
+
559
+
560
+ def _sqlite_index_names(connection: Connection, table_name: str) -> set[str]:
561
+ escaped_name = table_name.replace('"', '""')
562
+ rows = connection.execute(text(f'PRAGMA index_list("{escaped_name}")')).fetchall()
563
+ return {str(row[1]) for row in rows if len(row) > 1 and row[1] is not None}
564
+
565
+
566
+ def _postgresql_index_names(connection: Connection, table_name: str) -> set[str]:
567
+ rows = connection.execute(
568
+ text(
569
+ """
570
+ SELECT indexname
571
+ FROM pg_indexes
572
+ WHERE schemaname = ANY (current_schemas(false))
573
+ AND tablename = :table_name
574
+ """
575
+ ),
576
+ {"table_name": table_name},
577
+ ).fetchall()
578
+ return {str(row[0]) for row in rows if row and row[0] is not None}
579
+
580
+
581
+ def _read_index_names_for_drift(connection: Connection, table_name: str) -> set[str]:
582
+ if connection.dialect.name == "sqlite":
583
+ return _sqlite_index_names(connection, table_name)
584
+ if connection.dialect.name == "postgresql":
585
+ return _postgresql_index_names(connection, table_name)
586
+
587
+ inspector = inspect(connection)
588
+ if not inspector.has_table(table_name):
589
+ return set()
590
+ return {str(index["name"]) for index in inspector.get_indexes(table_name) if index.get("name") is not None}
591
+
592
+
593
+ def _manual_schema_drift_diffs(connection: Connection) -> tuple[str, ...]:
594
+ diffs: list[str] = []
595
+ for table_name, required_indexes in _MANUAL_DRIFT_INDEX_REQUIREMENTS.items():
596
+ existing_indexes = _read_index_names_for_drift(connection, table_name)
597
+ for index_name in sorted(required_indexes - existing_indexes):
598
+ diffs.append(repr(("missing_index", table_name, index_name)))
599
+ return tuple(diffs)
600
+
601
+
602
+ def _unwrap_schema_drift_diff(diff: object) -> object:
603
+ if isinstance(diff, list) and len(diff) == 1:
604
+ return diff[0]
605
+ return diff
606
+
607
+
608
+ def _is_ignored_schema_drift(connection: Connection, diff: object) -> bool:
609
+ diff = _unwrap_schema_drift_diff(diff)
610
+ if not isinstance(diff, tuple) or not diff:
611
+ return False
612
+
613
+ if diff[0] == "remove_column" and len(diff) >= 4:
614
+ column = diff[3]
615
+ column_name = getattr(column, "name", None)
616
+ if (str(diff[2]), str(column_name)) in _LEGACY_EXTRA_COLUMNS:
617
+ return True
618
+
619
+ if connection.dialect.name == "sqlite" and diff[0] == "modify_type" and len(diff) >= 7:
620
+ table_name = str(diff[2])
621
+ column_name = str(diff[3])
622
+ if (table_name, column_name) not in _SQLITE_FLOAT_TYPE_COMPAT_COLUMNS:
623
+ return False
624
+ existing_type = diff[5]
625
+ metadata_type = diff[6]
626
+ return str(existing_type).upper() == "REAL" and str(metadata_type).upper() in {"FLOAT", "REAL"}
627
+
628
+ return False
629
+
630
+
631
+ _OBJECT_ADDRESS_RE = re.compile(r" object at 0x[0-9a-fA-F]+>")
632
+
633
+
634
+ def _stable_diff_repr(diff: object) -> str:
635
+ """Render an autogenerate diff without CPython object addresses.
636
+
637
+ SQLAlchemy renders constraint members as ``<... object at 0x7f...>``, so the
638
+ repr of an otherwise identical foreign-key diff differs between two calls in
639
+ the same process. Drift output is compared and shown to operators, so it has
640
+ to be stable.
641
+ """
642
+ return _OBJECT_ADDRESS_RE.sub(" object>", repr(diff))
643
+
644
+
645
+ def check_schema_drift(database_url: str) -> tuple[str, ...]:
646
+ config = _build_alembic_config(database_url)
647
+ sync_database_url = _required_sqlalchemy_url(config)
648
+
649
+ with _sync_connection(sync_database_url) as connection:
650
+ migration_context = MigrationContext.configure(
651
+ connection=connection,
652
+ opts={
653
+ "target_metadata": Base.metadata,
654
+ "compare_type": True,
655
+ "compare_server_default": True,
656
+ },
657
+ )
658
+ with warnings.catch_warnings():
659
+ warnings.filterwarnings(
660
+ "ignore",
661
+ message=r"Skipped unsupported reflection of expression-based index .*",
662
+ )
663
+ warnings.filterwarnings(
664
+ "ignore",
665
+ message=r"autogenerate skipping metadata-specified expression-based index .*",
666
+ )
667
+ diffs = [
668
+ diff
669
+ for diff in compare_metadata(migration_context, Base.metadata)
670
+ if not _is_ignored_schema_drift(connection, diff)
671
+ ]
672
+ manual_diffs = _manual_schema_drift_diffs(connection)
673
+
674
+ return tuple(_stable_diff_repr(diff) for diff in diffs) + manual_diffs
675
+
676
+
677
+ _NO_LEGACY_BOOTSTRAP = LegacyBootstrapResult(
678
+ stamped_revision=None,
679
+ legacy_row_count=0,
680
+ unknown_migrations=(),
681
+ had_non_contiguous_entries=False,
682
+ )
683
+
684
+
685
+ def _resolved_lock_timeout_seconds(lock_timeout_seconds: float | None) -> float:
686
+ if lock_timeout_seconds is not None:
687
+ return lock_timeout_seconds
688
+ return get_settings().database_migration_lock_timeout_seconds
689
+
690
+
691
+ #: The revision that copied the legacy dashboard credentials onto the account
692
+ #: row (release N), and the one that drops the columns they lived in (this
693
+ #: release). The drop **descends** from the re-projection, so any upgrade that
694
+ #: crosses the drop re-projects first, in the same run: a database stamped at
695
+ #: any older revision reaches head in one command with its credentials intact,
696
+ #: and refusing that jump would keep every existing install from starting.
697
+ CREDENTIAL_REPROJECTION_REVISION = "20260909_020000_reproject_compat_admin_credentials"
698
+ CREDENTIAL_DROP_REVISION = "20260912_010000_drop_legacy_dashboard_credentials"
699
+
700
+ #: The table the dropped columns live in, and therefore the evidence that there
701
+ #: is an install here at all: a replica of an earlier release maps those columns
702
+ #: and loads this row whole, so until the table exists there is nothing for one
703
+ #: to be serving and nothing to drain.
704
+ _CREDENTIAL_MIRROR_TABLE = "dashboard_settings"
705
+
706
+ _CREDENTIAL_DROP_DRAIN_WARNING = (
707
+ "Dropping the legacy dashboard credential columns (%s). Replicas of any earlier release map these "
708
+ "columns and load the settings row whole, so their settings reads fail once this commits: stop them "
709
+ "before this migration runs, not after. Rollback is supported to the immediately previous release only."
710
+ )
711
+
712
+
713
+ #: Durable "the legacy credential layer is retired" marker, written into
714
+ #: ``runtime_sentinels`` by ``CREDENTIAL_DROP_REVISION`` and read back here.
715
+ #: Frozen as a literal on both sides for the same reason the revision freezes
716
+ #: its identifiers: neither may import the other.
717
+ LEGACY_CREDENTIALS_RETIRED_SENTINEL = "dashboard_legacy_credentials_retired"
718
+
719
+ _LEDGER_BEHIND_SCHEMA_WARNING = (
720
+ "Database has already retired the legacy dashboard credential columns (%s recorded it in "
721
+ "%s), but its Alembic ledger says %s, which does not include that revision. Stamping the "
722
+ "ledger at it rather than replaying the chain over this schema: the replay re-creates those "
723
+ "columns empty and the re-projection between them reads that emptiness as a removed "
724
+ "password, which would clear the account row that now holds the only dashboard credential "
725
+ "this install has."
726
+ )
727
+
728
+
729
+ def _remapped(revision: str) -> str:
730
+ return OLD_TO_NEW_REVISION_MAP.get(revision, revision)
731
+
732
+
733
+ def _pending_revisions(config: Config, current_revisions: Sequence[str], revision: str) -> frozenset[str]:
734
+ """Exactly the revisions ``command.upgrade(config, revision)`` would apply.
735
+
736
+ ``revision`` is whatever the caller passed, which is every target form
737
+ Alembic accepts: ``head``/``heads``, a full id, an unambiguous id *prefix*,
738
+ and a relative spec such as ``+1`` or ``<prefix>+2``. Resolving it by hand
739
+ -- special-casing ``head`` and otherwise treating the string as a literal
740
+ id -- silently mis-reads the last three: they resolve fine inside
741
+ ``command.upgrade`` a moment later, so the drop would run with nothing
742
+ said. This asks ``ScriptDirectory._upgrade_revs``, which is the function
743
+ ``command.upgrade`` itself calls to plan the run, so the check and the
744
+ command cannot disagree. ``iterate_revisions`` is the public spelling and
745
+ was the first attempt; it raises ``RangeNotAncestorError`` for a ledger
746
+ holding several heads when the target is on one branch, where the upgrade
747
+ succeeds -- one resolver, not two, is the point.
748
+
749
+ Empty on anything unanswerable: an id this build does not know (the caller
750
+ is a schema stamped ahead, which ``_run_upgrade_locked`` refuses on its own)
751
+ or a target Alembic itself cannot resolve (it raises the same error a beat
752
+ later, and a warning about a command that is going to fail helps nobody).
753
+ """
754
+
755
+ script_directory = ScriptDirectory.from_config(config)
756
+ known = _known_revisions(config)
757
+ resolved_current = tuple(_remapped(item) for item in current_revisions)
758
+ if any(item not in known for item in resolved_current):
759
+ return frozenset()
760
+ try:
761
+ # Alembic annotates ``current_rev`` as ``str``, but ``command.upgrade``
762
+ # itself hands it the migration context's *tuple* of heads and the
763
+ # resolver takes any collection. Narrowing to one would mis-plan a
764
+ # ledger that holds several.
765
+ steps = script_directory._upgrade_revs(revision, cast("Any", resolved_current))
766
+ except (CommandError, RevisionError):
767
+ return frozenset()
768
+ return frozenset(step.revision.revision for step in steps if step.revision.revision)
769
+
770
+
771
+ def _check_legacy_credential_drop(config: Config, sync_database_url: str, revision: str) -> None:
772
+ """Read the ledger *and* the schema, then decide; no side effects, no DDL."""
773
+
774
+ with _sync_connection(sync_database_url) as connection:
775
+ tables = _read_table_names(connection)
776
+ current_revisions = (
777
+ _read_current_revisions_from_connection(connection) if _ALEMBIC_VERSION_TABLE in tables else ()
778
+ )
779
+ has_existing_schema = _CREDENTIAL_MIRROR_TABLE in tables
780
+ check_legacy_credential_drop(
781
+ config,
782
+ current_revisions,
783
+ revision,
784
+ has_existing_schema=has_existing_schema,
785
+ )
786
+
787
+
788
+ def check_legacy_credential_drop(
789
+ config: Config,
790
+ current_revisions: Sequence[str],
791
+ revision: str,
792
+ *,
793
+ has_existing_schema: bool,
794
+ ) -> None:
795
+ """State the drain requirement before the legacy credential columns go.
796
+
797
+ Called before any DDL, and it deliberately claims only what the ledger and
798
+ the schema can show. Nothing is refused: ``CREDENTIAL_REPROJECTION_REVISION``
799
+ is an ancestor of ``CREDENTIAL_DROP_REVISION``, so a database stamped
800
+ anywhere below both reaches head in one command with the credentials copied
801
+ onto the account rows before the columns they came from disappear, and the
802
+ ordering that makes that true is pinned by a test rather than re-checked
803
+ here. What no signal anywhere can show is whether a replica of an earlier
804
+ release is serving right now, so the drain requirement is a warning an
805
+ operator must act on rather than a condition this process can verify.
806
+
807
+ An empty ledger is *not* on its own a fresh install, which is why
808
+ ``has_existing_schema`` is asked for separately. ``alembic_version`` can be
809
+ absent, truncated, or restored without its rows over a schema that has been
810
+ serving for months, and that database reads identically to a new one -- yet
811
+ it is the one that most needs to be told, because the replay it is about to
812
+ run reaches ``CREDENTIAL_DROP_REVISION`` and drops the columns for real (the
813
+ chain's early revisions are inspector-guarded, so replaying over an existing
814
+ schema does not fail first). The fresh case is therefore read off the data:
815
+ ``_CREDENTIAL_MIRROR_TABLE`` is where the doomed columns live, and until it
816
+ exists there is no install here for a replica of an earlier release to be
817
+ serving. Both halves have to hold -- an empty ledger *and* no such table.
818
+
819
+ ``config.attributes["codex_lb_fresh_install"]`` is deliberately not that
820
+ signal: it is false whenever an ``alembic_version`` table merely exists, so
821
+ an empty one left behind by a crashed first run would turn today's missing
822
+ warning into a warning about a database with nothing to drain -- and a
823
+ published revision already reads that attribute to pick its defaults, so it
824
+ is not free to redefine.
825
+ """
826
+
827
+ if not current_revisions and not has_existing_schema:
828
+ # No ledger and no schema: a fresh install. The chain creates the
829
+ # tables without the dropped columns ever holding anything.
830
+ return
831
+
832
+ if CREDENTIAL_DROP_REVISION in _pending_revisions(config, current_revisions, revision):
833
+ logger.warning(_CREDENTIAL_DROP_DRAIN_WARNING, CREDENTIAL_DROP_REVISION)
834
+
835
+
836
+ def _read_runtime_sentinel(connection: Connection, name: str) -> str | None:
837
+ if _RUNTIME_SENTINELS_TABLE not in _read_table_names(connection):
838
+ return None
839
+ row = connection.execute(
840
+ text(f"SELECT value FROM {_RUNTIME_SENTINELS_TABLE} WHERE name = :name"),
841
+ {"name": name},
842
+ ).first()
843
+ return None if row is None else str(row[0])
844
+
845
+
846
+ def _ledger_has_applied(config: Config, current_revisions: Sequence[str], revision: str) -> bool:
847
+ """Whether ``revision`` is at or below any of ``current_revisions``."""
848
+
849
+ script_directory = ScriptDirectory.from_config(config)
850
+ return any(
851
+ item.revision == revision
852
+ for current in current_revisions
853
+ for item in script_directory.iterate_revisions(current, "base")
854
+ )
855
+
856
+
857
+ def _reconcile_retired_credential_ledger(config: Config) -> str | None:
858
+ """Re-stamp a ledger that sits below a schema which already retired the credentials.
859
+
860
+ The same shape as ``_bootstrap_legacy_history``: a database whose ledger
861
+ does not describe it is placed at the revision its own data proves it
862
+ reached, rather than replayed. The evidence is the marker
863
+ ``CREDENTIAL_DROP_REVISION`` writes into ``runtime_sentinels``, and only
864
+ that revision writes it (its downgrade removes it again), so the marker
865
+ means the three columns are gone -- whatever the ledger says. A ledger that
866
+ disagrees has been lost, rewound or restored from a partial backup, and it
867
+ is the ledger that is wrong.
868
+
869
+ The evidence has to be data, because the ledger is the thing that went
870
+ missing. And the replay it prevents is not merely wasteful, it is
871
+ destructive: ``20260213_000600``/``20260213_000700`` re-add
872
+ ``password_hash``, ``totp_secret_encrypted`` and ``totp_last_verified_step``
873
+ to an existing ``dashboard_settings`` as **empty** columns, and
874
+ ``CREDENTIAL_REPROJECTION_REVISION`` then reads that emptiness as "the
875
+ previous release removed the password" and NULLs the credential on the
876
+ account row -- which, after this release, is the only copy, so the install
877
+ is locked out of its own dashboard. (Replaying
878
+ ``20260909_010000_add_dashboard_users`` over a schema whose ledger was
879
+ rewound *past* the column re-creation fails outright instead, on
880
+ ``no such column: password_hash``.) Both of those revisions are published
881
+ and must not be edited to defend themselves -- an install that has applied
882
+ a revision never applies it again -- so the defence is here, before the
883
+ first one runs.
884
+
885
+ Call this *after* ``_remap_legacy_alembic_revisions``: it needs a ledger
886
+ Alembic can resolve, and a ledger it cannot is left alone so the upgrade
887
+ raises its own error rather than a stamp raising one earlier.
888
+
889
+ Returns the stamped revision, or ``None`` when nothing was stamped.
890
+ """
891
+
892
+ sync_database_url = _required_sqlalchemy_url(config)
893
+ with _sync_connection(sync_database_url) as connection:
894
+ has_ledger = _ALEMBIC_VERSION_TABLE in _read_table_names(connection)
895
+ marker = _read_runtime_sentinel(connection, LEGACY_CREDENTIALS_RETIRED_SENTINEL)
896
+ current_revisions = _read_current_revisions_from_connection(connection) if has_ledger else ()
897
+
898
+ if marker is None:
899
+ return None
900
+ if any(revision not in _known_revisions(config) for revision in current_revisions):
901
+ return None
902
+ if current_revisions and _ledger_has_applied(config, current_revisions, CREDENTIAL_DROP_REVISION):
903
+ return None
904
+
905
+ logger.warning(
906
+ _LEDGER_BEHIND_SCHEMA_WARNING,
907
+ marker,
908
+ _RUNTIME_SENTINELS_TABLE,
909
+ ",".join(current_revisions) if current_revisions else f"no {_ALEMBIC_VERSION_TABLE} table",
910
+ )
911
+ _ensure_alembic_version_table_capacity(config)
912
+ command.stamp(config, CREDENTIAL_DROP_REVISION)
913
+ return CREDENTIAL_DROP_REVISION
914
+
915
+
916
+ def _schema_ahead_error(state: MigrationState) -> MigrationBootstrapError:
917
+ return MigrationBootstrapError(
918
+ f"Database schema revision(s) {','.join(state.unknown_revisions)} are not known to this build "
919
+ f"(head={state.head_revision}); the schema was likely migrated by a newer version. "
920
+ "Deploy a matching or newer image, or run an Alembic downgrade to a revision this build knows."
921
+ )
922
+
923
+
924
+ def run_upgrade(
925
+ database_url: str,
926
+ revision: str = "head",
927
+ *,
928
+ bootstrap_legacy: bool,
929
+ auto_remap_legacy_revisions: bool = True,
930
+ lock_timeout_seconds: float | None = None,
931
+ ) -> MigrationRunResult:
932
+ config = _build_alembic_config(database_url)
933
+ sync_database_url = _required_sqlalchemy_url(config)
934
+ with migration_lock(
935
+ sync_database_url,
936
+ timeout_seconds=_resolved_lock_timeout_seconds(lock_timeout_seconds),
937
+ ):
938
+ return _run_upgrade_locked(
939
+ config,
940
+ database_url,
941
+ revision,
942
+ bootstrap_legacy=bootstrap_legacy,
943
+ auto_remap_legacy_revisions=auto_remap_legacy_revisions,
944
+ )
945
+
946
+
947
+ def _run_upgrade_locked(
948
+ config: Config,
949
+ database_url: str,
950
+ revision: str,
951
+ *,
952
+ bootstrap_legacy: bool,
953
+ auto_remap_legacy_revisions: bool,
954
+ ) -> MigrationRunResult:
955
+ state_before = inspect_migration_state(database_url)
956
+ if state_before.is_ahead:
957
+ raise _schema_ahead_error(state_before)
958
+
959
+ # Post-acquire re-check: a peer holding the lock first may already have
960
+ # migrated to head. When alembic_version exists the legacy bootstrap never
961
+ # stamps, and current == head means no legacy remap is pending, so skipping
962
+ # is safe and lets the losing replica complete startup successfully.
963
+ if revision == "head" and state_before.has_alembic_version_table and not state_before.needs_upgrade:
964
+ logger.info(
965
+ "Database schema already at Alembic head revision=%s; skipping upgrade "
966
+ "(migrations were applied by this or another replica)",
967
+ state_before.current_revision,
968
+ )
969
+ return MigrationRunResult(
970
+ current_revision=state_before.current_revision,
971
+ bootstrap=_NO_LEGACY_BOOTSTRAP,
972
+ )
973
+
974
+ config.attributes["codex_lb_fresh_install"] = (
975
+ state_before.current_revision is None
976
+ and not state_before.has_alembic_version_table
977
+ and not state_before.has_legacy_migrations_table
978
+ )
979
+
980
+ bootstrap_result = _NO_LEGACY_BOOTSTRAP
981
+
982
+ if bootstrap_legacy:
983
+ bootstrap_result = _bootstrap_legacy_history(config)
984
+ if bootstrap_result.stamped_revision is not None:
985
+ config.attributes["codex_lb_fresh_install"] = False
986
+
987
+ _ensure_alembic_version_table_capacity(config)
988
+ if auto_remap_legacy_revisions:
989
+ _remap_legacy_alembic_revisions(config)
990
+ # The second ledger repair, and the one that must not be skipped: replaying
991
+ # the chain over a schema that already retired the legacy credential
992
+ # columns destroys the account credentials (see the function). It runs
993
+ # after the remap because it needs a ledger Alembic can resolve.
994
+ if _reconcile_retired_credential_ledger(config) is not None:
995
+ config.attributes["codex_lb_fresh_install"] = False
996
+ # Last read before the first DDL: the legacy bootstrap, the remap and the
997
+ # reconciliation may all have moved the ledger, and the question is what
998
+ # this database has actually applied at the moment the upgrade starts.
999
+ _check_legacy_credential_drop(config, _required_sqlalchemy_url(config), revision)
1000
+ command.upgrade(config, revision)
1001
+
1002
+ sync_database_url = _required_sqlalchemy_url(config)
1003
+ current_revision = _read_current_revision(sync_database_url)
1004
+
1005
+ if bootstrap_result.unknown_migrations:
1006
+ logger.warning(
1007
+ "Unknown legacy migration names detected names=%s",
1008
+ bootstrap_result.unknown_migrations,
1009
+ )
1010
+ if bootstrap_result.had_non_contiguous_entries:
1011
+ logger.warning("Legacy migration table has non-contiguous applied entries")
1012
+
1013
+ return MigrationRunResult(current_revision=current_revision, bootstrap=bootstrap_result)
1014
+
1015
+
1016
+ async def run_startup_migrations(database_url: str) -> MigrationRunResult:
1017
+ auto_remap = get_settings().database_alembic_auto_remap_enabled
1018
+ return await to_thread.run_sync(
1019
+ lambda: run_upgrade(
1020
+ database_url,
1021
+ "head",
1022
+ bootstrap_legacy=True,
1023
+ auto_remap_legacy_revisions=auto_remap,
1024
+ ),
1025
+ )
1026
+
1027
+
1028
+ def current_revision(database_url: str) -> str | None:
1029
+ state = inspect_migration_state(database_url)
1030
+ return state.current_revision
1031
+
1032
+
1033
+ def stamp_revision(database_url: str, revision: str, *, lock_timeout_seconds: float | None = None) -> None:
1034
+ config = _build_alembic_config(database_url)
1035
+ sync_database_url = _required_sqlalchemy_url(config)
1036
+ with migration_lock(
1037
+ sync_database_url,
1038
+ timeout_seconds=_resolved_lock_timeout_seconds(lock_timeout_seconds),
1039
+ ):
1040
+ _ensure_alembic_version_table_capacity(config)
1041
+ command.stamp(config, revision)
1042
+
1043
+
1044
+ def wait_for_connection(
1045
+ database_url: str,
1046
+ *,
1047
+ timeout_seconds: float,
1048
+ interval_seconds: float = 2.0,
1049
+ ) -> None:
1050
+ if timeout_seconds <= 0:
1051
+ raise ValueError("timeout_seconds must be greater than 0")
1052
+ if interval_seconds <= 0:
1053
+ raise ValueError("interval_seconds must be greater than 0")
1054
+
1055
+ started_at = time.monotonic()
1056
+ last_error: Exception | None = None
1057
+ sync_database_url = to_sync_database_url(database_url)
1058
+
1059
+ while True:
1060
+ try:
1061
+ with _sync_connection(sync_database_url):
1062
+ return
1063
+ except Exception as exc:
1064
+ last_error = exc
1065
+ elapsed = time.monotonic() - started_at
1066
+ if elapsed >= timeout_seconds:
1067
+ if last_error is not None:
1068
+ raise TimeoutError(
1069
+ f"Timed out waiting for database connectivity after {timeout_seconds:.1f}s: {last_error}"
1070
+ ) from last_error
1071
+ raise TimeoutError(f"Timed out waiting for database connectivity after {timeout_seconds:.1f}s")
1072
+ time.sleep(min(interval_seconds, timeout_seconds - elapsed))
1073
+
1074
+
1075
+ def wait_for_head(
1076
+ database_url: str,
1077
+ *,
1078
+ timeout_seconds: float,
1079
+ interval_seconds: float = 2.0,
1080
+ ) -> MigrationState:
1081
+ if timeout_seconds <= 0:
1082
+ raise ValueError("timeout_seconds must be greater than 0")
1083
+ if interval_seconds <= 0:
1084
+ raise ValueError("interval_seconds must be greater than 0")
1085
+
1086
+ started_at = time.monotonic()
1087
+ last_error: Exception | None = None
1088
+
1089
+ while True:
1090
+ try:
1091
+ state = inspect_migration_state(database_url)
1092
+ if not state.needs_upgrade:
1093
+ return state
1094
+ except Exception as exc:
1095
+ last_error = exc
1096
+ elapsed = time.monotonic() - started_at
1097
+ if elapsed >= timeout_seconds:
1098
+ if last_error is not None:
1099
+ raise TimeoutError(
1100
+ f"Timed out waiting for database schema to reach Alembic head after {timeout_seconds:.1f}s: "
1101
+ f"{last_error}"
1102
+ ) from last_error
1103
+ raise TimeoutError(
1104
+ f"Timed out waiting for database schema to reach Alembic head after {timeout_seconds:.1f}s"
1105
+ )
1106
+ time.sleep(min(interval_seconds, timeout_seconds - elapsed))
1107
+
1108
+
1109
+ def _non_empty_database_url(value: str) -> str:
1110
+ if value == "":
1111
+ raise argparse.ArgumentTypeError("database URL must not be empty")
1112
+ return value
1113
+
1114
+
1115
+ def _parse_args() -> argparse.Namespace:
1116
+ parser = argparse.ArgumentParser(description="Database migration utility for codex-lb.")
1117
+ parser.add_argument(
1118
+ "--db-url",
1119
+ type=_non_empty_database_url,
1120
+ default=None,
1121
+ help="Database URL to migrate. Defaults to CODEX_LB_DATABASE_URL from settings.",
1122
+ )
1123
+
1124
+ subparsers = parser.add_subparsers(dest="command", required=True)
1125
+
1126
+ upgrade_parser = subparsers.add_parser("upgrade", help="Upgrade schema to a target revision.")
1127
+ upgrade_parser.add_argument("revision", nargs="?", default="head")
1128
+ upgrade_parser.add_argument(
1129
+ "--no-bootstrap-legacy",
1130
+ action="store_true",
1131
+ help="Disable automatic legacy schema_migrations bootstrap before upgrade.",
1132
+ )
1133
+ upgrade_parser.add_argument(
1134
+ "--no-auto-remap-legacy-revisions",
1135
+ action="store_true",
1136
+ help="Disable automatic remap of legacy Alembic revision IDs.",
1137
+ )
1138
+
1139
+ subparsers.add_parser("current", help="Print current alembic revision.")
1140
+
1141
+ subparsers.add_parser("check", help="Check Alembic policy and model/schema drift.")
1142
+
1143
+ wait_parser = subparsers.add_parser(
1144
+ "wait-for-head",
1145
+ help="Wait until the database schema reaches Alembic head without applying migrations locally.",
1146
+ )
1147
+ wait_parser.add_argument(
1148
+ "--timeout-seconds",
1149
+ type=float,
1150
+ default=300.0,
1151
+ help="Maximum seconds to wait for the schema to reach Alembic head.",
1152
+ )
1153
+ wait_parser.add_argument(
1154
+ "--interval-seconds",
1155
+ type=float,
1156
+ default=2.0,
1157
+ help="Polling interval in seconds while waiting for the schema to reach Alembic head.",
1158
+ )
1159
+
1160
+ connect_parser = subparsers.add_parser(
1161
+ "wait-for-connection",
1162
+ help="Wait until the database accepts connections without applying migrations locally.",
1163
+ )
1164
+ connect_parser.add_argument(
1165
+ "--timeout-seconds",
1166
+ type=float,
1167
+ default=300.0,
1168
+ help="Maximum seconds to wait for database connectivity.",
1169
+ )
1170
+ connect_parser.add_argument(
1171
+ "--interval-seconds",
1172
+ type=float,
1173
+ default=2.0,
1174
+ help="Polling interval in seconds while waiting for database connectivity.",
1175
+ )
1176
+
1177
+ stamp_parser = subparsers.add_parser("stamp", help="Set current revision without running migrations.")
1178
+ stamp_parser.add_argument("revision")
1179
+
1180
+ return parser.parse_args()
1181
+
1182
+
1183
+ def main() -> None:
1184
+ args = _parse_args()
1185
+ database_url = get_settings().database_url if args.db_url is None else args.db_url
1186
+
1187
+ if args.command == "upgrade":
1188
+ result = run_upgrade(
1189
+ database_url,
1190
+ args.revision,
1191
+ bootstrap_legacy=not bool(args.no_bootstrap_legacy),
1192
+ auto_remap_legacy_revisions=not bool(args.no_auto_remap_legacy_revisions),
1193
+ )
1194
+ print(f"current_revision={result.current_revision or 'none'}")
1195
+ if result.bootstrap.stamped_revision:
1196
+ print(f"legacy_bootstrap_stamped={result.bootstrap.stamped_revision}")
1197
+ return
1198
+
1199
+ if args.command == "current":
1200
+ revision = current_revision(database_url)
1201
+ print(revision or "none")
1202
+ return
1203
+
1204
+ if args.command == "check":
1205
+ policy_violations = check_migration_policy(database_url)
1206
+ if policy_violations:
1207
+ print("migration_policy_violations_detected")
1208
+ for violation in policy_violations:
1209
+ print(violation)
1210
+ drift = check_schema_drift(database_url)
1211
+ if drift:
1212
+ print("schema_drift_detected")
1213
+ for diff in drift:
1214
+ print(diff)
1215
+ if policy_violations or drift:
1216
+ raise SystemExit(1)
1217
+ print("migration_policy=ok")
1218
+ print("schema_drift=none")
1219
+ return
1220
+
1221
+ if args.command == "stamp":
1222
+ stamp_revision(database_url, args.revision)
1223
+ print(f"stamped={args.revision}")
1224
+ return
1225
+
1226
+ if args.command == "wait-for-head":
1227
+ state = wait_for_head(
1228
+ database_url,
1229
+ timeout_seconds=args.timeout_seconds,
1230
+ interval_seconds=args.interval_seconds,
1231
+ )
1232
+ print(f"current_revision={state.current_revision or 'none'}")
1233
+ print(f"head_revision={state.head_revision}")
1234
+ return
1235
+
1236
+ if args.command == "wait-for-connection":
1237
+ wait_for_connection(
1238
+ database_url,
1239
+ timeout_seconds=args.timeout_seconds,
1240
+ interval_seconds=args.interval_seconds,
1241
+ )
1242
+ print("database_connection=ready")
1243
+ return
1244
+
1245
+ raise RuntimeError(f"unsupported command: {args.command}")
1246
+
1247
+
1248
+ if __name__ == "__main__":
1249
+ main()