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/session.py ADDED
@@ -0,0 +1,1295 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ import os
7
+ import sqlite3
8
+ import time
9
+ from contextlib import asynccontextmanager
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable, Protocol, TypeVar
13
+
14
+ import anyio
15
+ from anyio import to_thread
16
+ from sqlalchemy import event, text
17
+ from sqlalchemy import util as sqlalchemy_util
18
+ from sqlalchemy.engine import Connection, Engine
19
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
20
+ from sqlalchemy.pool import NullPool
21
+
22
+ from app.core.config.settings import get_settings
23
+ from app.core.utils.shared_future import _await_cleanup_deferring_cancellation
24
+ from app.db.sqlite_utils import (
25
+ IntegrityCheck,
26
+ SqliteFileIdentity,
27
+ SqliteIntegrityCheckMode,
28
+ SqliteRunState,
29
+ SqliteRunStateDurabilityError,
30
+ _sqlite_file_identity,
31
+ acquire_sqlite_runstate_lock,
32
+ check_sqlite_integrity,
33
+ integrity_check_pragma_name,
34
+ normalize_sqlite_url,
35
+ read_sqlite_runstate_record,
36
+ release_sqlite_runstate_lock,
37
+ sqlite_db_path_from_url,
38
+ sqlite_url_is_memory,
39
+ write_sqlite_runstate,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from app.db.migrate import MigrationRunResult, MigrationState
44
+
45
+ _settings = get_settings()
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ _SQLITE_BUSY_TIMEOUT_MS = 30_000
50
+ _SQLITE_BUSY_TIMEOUT_SECONDS = _SQLITE_BUSY_TIMEOUT_MS / 1000
51
+ # A write transaction holding SQLite's single writer slot past the busy
52
+ # timeout is exactly the holder that makes every other writer surface
53
+ # "database is locked" (issue #1682); the watchdog below reports it with the
54
+ # statements it ran, since the stall is nondeterministic and self-recovers.
55
+ _SQLITE_LONG_WRITE_TRANSACTION_WARN_SECONDS = _SQLITE_BUSY_TIMEOUT_SECONDS
56
+ _SQLITE_WRITE_STATEMENT_PREFIXES = (
57
+ "insert",
58
+ "update",
59
+ "delete",
60
+ "replace",
61
+ "create",
62
+ "drop",
63
+ "alter",
64
+ "vacuum",
65
+ # BEGIN IMMEDIATE/EXCLUSIVE acquire the writer slot with no DML at all
66
+ # (e.g. AccountsRepository._acquire_sqlite_merge_lock); a plain deferred
67
+ # BEGIN does not and stays untracked.
68
+ "begin immediate",
69
+ "begin exclusive",
70
+ )
71
+ _SQLITE_WATCHDOG_STATEMENT_PREVIEW_CHARS = 300
72
+ # Initial observation bound for file-backed SQLite teardown. A pending worker
73
+ # can retain the single writer slot (issue #1682); after the completion grace,
74
+ # reclamation attempts to interrupt the driver and invalidate its connection.
75
+ _SQLITE_TEARDOWN_TIMEOUT_SECONDS = _SQLITE_BUSY_TIMEOUT_SECONDS / 6
76
+ # A worker can finish while loop callbacks remain queued (issue #2029). Allow
77
+ # a separate bounded opportunity to observe successful task completion before
78
+ # reclamation. A genuinely pending teardown can take this additional 0.75s.
79
+ _SQLITE_TEARDOWN_COMPLETION_GRACE_SECONDS = _SQLITE_BUSY_TIMEOUT_SECONDS / 40
80
+ # Session.info marker set once a teardown step was abandoned as wedged: the
81
+ # session must never be driven by another coroutine again (the abandoned
82
+ # greenlet may still resume), and the deferred cleanup takes over.
83
+ _SQLITE_TEARDOWN_WEDGED_INFO_KEY = "sqlite_teardown_wedged"
84
+ # Abandoned wedged teardown tasks and the deferred bookkeeping closes they
85
+ # schedule on late completion, owned until completion so shutdown (close_db)
86
+ # drains them instead of closing the event loop over pending tasks. The
87
+ # bookkeeping closes are bounded by _SQLITE_TEARDOWN_TIMEOUT_SECONDS; an
88
+ # abandoned teardown may outlive its reclaim (the interrupt is best-effort),
89
+ # so the close_db drain is explicitly bounded as a whole.
90
+ _wedged_teardown_cleanup_tasks: set[asyncio.Task[Any]] = set()
91
+
92
+ # PostgreSQL pool checkout timeout and connection recycle window. Fixed
93
+ # application constants (issue #1340): recycle keeps pooled connections
94
+ # younger than any reasonable server/proxy keep-alive boundary, and neither
95
+ # value is an operator decision. Pool sizing stays configurable via
96
+ # ``database_pool_size`` / ``database_max_overflow``.
97
+ _POSTGRES_POOL_TIMEOUT_SECONDS = 30.0
98
+ _POSTGRES_POOL_RECYCLE_SECONDS = 1800
99
+ # Per-statement execution bound (issue #1971). One query hung on a half-dead
100
+ # connection wedged the process-global settings-cache lock — and every
101
+ # http-bridge submit parked behind it while holding its session's
102
+ # pending_lock — for days. No runtime query legitimately runs this long
103
+ # (retention and cleanup work is chunked); Alembic migrations use their own
104
+ # synchronous engine and are not bounded by this. Fixed application constant
105
+ # like the pool knobs above: not an operator decision.
106
+ _POSTGRES_COMMAND_TIMEOUT_SECONDS = 60.0
107
+ _database_url = normalize_sqlite_url(_settings.database_url)
108
+
109
+
110
+ class _PostgresPooledEngineRole(StrEnum):
111
+ REQUEST_PATH = "request_path"
112
+ BACKGROUND_TASK = "background_task"
113
+
114
+
115
+ # The owned launcher pins one worker per replica. Derive its two-engine
116
+ # PostgreSQL budget from the roles that the real creation paths must declare.
117
+ _POSTGRES_POOLED_ENGINE_ROLES = tuple(_PostgresPooledEngineRole)
118
+ _POSTGRES_POOLED_ENGINES_PER_WORKER = len(_POSTGRES_POOLED_ENGINE_ROLES)
119
+
120
+
121
+ def _is_sqlite_url(url: str) -> bool:
122
+ return url.startswith("sqlite+aiosqlite:///") or url.startswith("sqlite:///")
123
+
124
+
125
+ def _is_sqlite_memory_url(url: str) -> bool:
126
+ return sqlite_url_is_memory(url)
127
+
128
+
129
+ def _postgres_async_connect_args(url: str) -> dict[str, object] | None:
130
+ if not url.startswith("postgresql+asyncpg://"):
131
+ return None
132
+ # Pin the asyncpg session time zone to UTC. The application persists naive
133
+ # UTC datetimes (see app.core.utils.time.utcnow) into timestamptz columns.
134
+ # asyncpg binds a naive datetime using the connection's session time zone,
135
+ # so if that time zone follows the container's TZ (e.g. Europe/Amsterdam)
136
+ # PostgreSQL shifts every written timestamp away from real UTC. That skew is
137
+ # silent but corrupts every wall-clock comparison the coordinator relies on:
138
+ # ring-membership heartbeats look perpetually stale, leader election and the
139
+ # bridge-session cleanup stop running, and account/stream lease expiry is
140
+ # mis-evaluated. Forcing UTC keeps stored timestamps correct regardless of
141
+ # the container time zone.
142
+ connect_args: dict[str, object] = {
143
+ "server_settings": {"timezone": "UTC"},
144
+ # Bound every statement so a query stalled on a half-dead connection
145
+ # cannot hold application locks forever (issue #1971). asyncpg cancels
146
+ # the statement server-side and raises, surfacing the stall as an
147
+ # error instead of an unbounded await.
148
+ "command_timeout": _POSTGRES_COMMAND_TIMEOUT_SECONDS,
149
+ }
150
+ if os.environ.get("CODEX_LB_TEST_DATABASE_URL"):
151
+ connect_args["prepared_statement_cache_size"] = 0
152
+ return connect_args
153
+
154
+
155
+ def _postgres_async_engine_kwargs(url: str) -> dict[str, object]:
156
+ """Engine kwargs shared by the main and background PostgreSQL engines.
157
+
158
+ The background engine always derives its pool sizing from the main pool
159
+ settings; it exists to isolate background-task checkouts, not to be sized
160
+ independently.
161
+ """
162
+ connect_args = _postgres_async_connect_args(url)
163
+ kwargs: dict[str, object] = {"connect_args": connect_args or {}}
164
+ if os.environ.get("CODEX_LB_TEST_DATABASE_URL") and url.startswith("postgresql+asyncpg://"):
165
+ kwargs["poolclass"] = NullPool
166
+ else:
167
+ kwargs["pool_size"] = _settings.database_pool_size
168
+ kwargs["max_overflow"] = _settings.database_max_overflow
169
+ kwargs["pool_timeout"] = _POSTGRES_POOL_TIMEOUT_SECONDS
170
+ # Detect server-side connection drops (idle timeout, restart, network reset)
171
+ # before the first real query, and cycle long-lived connections so they
172
+ # never reach an upstream keep-alive boundary. SQLite paths do not need
173
+ # either knob — aiosqlite has no analogous server-side disconnect.
174
+ kwargs["pool_pre_ping"] = True
175
+ kwargs["pool_recycle"] = _POSTGRES_POOL_RECYCLE_SECONDS
176
+ return kwargs
177
+
178
+
179
+ def _create_postgres_async_engine(url: str, *, role: _PostgresPooledEngineRole) -> AsyncEngine:
180
+ if not isinstance(role, _PostgresPooledEngineRole):
181
+ raise TypeError(f"PostgreSQL engine role must be declared in {_PostgresPooledEngineRole.__name__}")
182
+ return create_async_engine(
183
+ url,
184
+ echo=False,
185
+ **_postgres_async_engine_kwargs(url),
186
+ )
187
+
188
+
189
+ def _sqlite_file_async_engine_kwargs() -> dict[str, object]:
190
+ return {
191
+ "poolclass": NullPool,
192
+ "connect_args": {"timeout": _SQLITE_BUSY_TIMEOUT_SECONDS},
193
+ }
194
+
195
+
196
+ def _configure_sqlite_engine(engine: Engine, *, enable_wal: bool) -> None:
197
+ @event.listens_for(engine, "connect")
198
+ def _set_sqlite_pragmas(dbapi_connection: sqlite3.Connection, _: object) -> None:
199
+ cursor: sqlite3.Cursor = dbapi_connection.cursor()
200
+ try:
201
+ if enable_wal:
202
+ cursor.execute("PRAGMA journal_mode=WAL")
203
+ cursor.execute("PRAGMA synchronous=NORMAL")
204
+ cursor.execute("PRAGMA foreign_keys=ON")
205
+ cursor.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}")
206
+ finally:
207
+ cursor.close()
208
+
209
+ _install_sqlite_long_write_watchdog(engine)
210
+
211
+
212
+ def _current_task_name_best_effort() -> str:
213
+ # Sync engine events run inside the greenlet driving the async engine on
214
+ # the event loop thread, so the owning task is normally visible; never let
215
+ # diagnostics raise into the query path.
216
+ try:
217
+ task = asyncio.current_task()
218
+ except RuntimeError:
219
+ return "<no-loop>"
220
+ if task is None:
221
+ return "<no-task>"
222
+ return task.get_name()
223
+
224
+
225
+ def _install_sqlite_long_write_watchdog(engine: Engine) -> None:
226
+ """Report write transactions that outlive the SQLite busy timeout.
227
+
228
+ In WAL mode a transaction takes the single writer slot at its first write
229
+ statement, not at BEGIN, so the window is measured from the first write to
230
+ commit/rollback. The report fires when the holder finally ends — the stall
231
+ in issue #1682 self-recovers, so identifying the holder post-hoc is the
232
+ point; a live sampler is not needed to attribute it.
233
+ """
234
+
235
+ def _live_connection_info(conn: object) -> dict[str, object] | None:
236
+ # Connection.info may reconnect an invalidated driver connection. A
237
+ # cancelled statement requires rollback first; diagnostics must never
238
+ # prevent that rollback with PendingRollbackError or ResourceClosedError.
239
+ if getattr(conn, "invalidated", False) or getattr(conn, "closed", False):
240
+ return None
241
+ return getattr(conn, "info", None)
242
+
243
+ @event.listens_for(engine, "after_cursor_execute")
244
+ def _track_write_statements(
245
+ conn: object,
246
+ cursor: object,
247
+ statement: str,
248
+ parameters: object,
249
+ context: object,
250
+ executemany: bool,
251
+ ) -> None:
252
+ # after_cursor_execute, not before: the first write statement may wait
253
+ # up to busy_timeout for the writer slot before failing, and timing
254
+ # from before the execute would report that victim as the holder. The
255
+ # slot is only held once a write statement has SUCCEEDED, so the clock
256
+ # starts there.
257
+ stripped = statement.lstrip().lower()
258
+ if not stripped.startswith(_SQLITE_WRITE_STATEMENT_PREFIXES):
259
+ return
260
+ info = _live_connection_info(conn)
261
+ if info is None:
262
+ return
263
+ preview = statement[:_SQLITE_WATCHDOG_STATEMENT_PREVIEW_CHARS]
264
+ if "sqlite_write_started_at" not in info:
265
+ info["sqlite_write_started_at"] = time.monotonic()
266
+ info["sqlite_first_write_statement"] = preview
267
+ info["sqlite_write_task"] = _current_task_name_best_effort()
268
+ info["sqlite_last_write_statement"] = preview
269
+
270
+ def _finalize_pending_report(info: dict[str, object]) -> None:
271
+ pending = info.pop("sqlite_write_pending_report", None)
272
+ if not isinstance(pending, tuple):
273
+ return
274
+ started_at, outcome, first_statement, last_statement, task_name = pending
275
+ held_seconds = time.monotonic() - float(started_at)
276
+ if held_seconds < _SQLITE_LONG_WRITE_TRANSACTION_WARN_SECONDS:
277
+ return
278
+ logger.warning(
279
+ "sqlite_long_write_transaction held_seconds=%.1f outcome=%s task=%s first_statement=%r "
280
+ "last_statement=%r — this writer starved every other writer past busy_timeout (issue #1682)",
281
+ held_seconds,
282
+ outcome,
283
+ task_name,
284
+ first_statement,
285
+ last_statement,
286
+ )
287
+
288
+ def _mark_transaction_ending(conn: object, *, outcome: str) -> None:
289
+ # ConnectionEvents.commit/rollback fire BEFORE the DBAPI call, and a
290
+ # wedged rollback is exactly the holder this watchdog hunts — reporting
291
+ # here would exclude the wedge itself from the measured hold. Stash the
292
+ # report and finalize it at the first proof the DBAPI transaction ended:
293
+ # the connection's next transaction beginning, or the connection going
294
+ # back to the pool.
295
+ info = _live_connection_info(conn)
296
+ if info is None:
297
+ return
298
+ started_at = info.pop("sqlite_write_started_at", None)
299
+ first_statement = info.pop("sqlite_first_write_statement", None)
300
+ last_statement = info.pop("sqlite_last_write_statement", None)
301
+ task_name = info.pop("sqlite_write_task", None)
302
+ if started_at is None:
303
+ # A rollback after a commit whose DBAPI call raised: the pending
304
+ # report already holds outcome=commit, but the transaction is in
305
+ # fact ending by rollback — rewrite the outcome so the report does
306
+ # not claim a durable commit that never happened.
307
+ pending = info.get("sqlite_write_pending_report")
308
+ if outcome == "rollback" and isinstance(pending, tuple) and pending[1] == "commit":
309
+ info["sqlite_write_pending_report"] = (pending[0], "commit_failed_rollback", *pending[2:])
310
+ return
311
+ info["sqlite_write_pending_report"] = (started_at, outcome, first_statement, last_statement, task_name)
312
+
313
+ @event.listens_for(engine, "commit")
314
+ def _mark_on_commit(conn: object) -> None:
315
+ _mark_transaction_ending(conn, outcome="commit")
316
+
317
+ @event.listens_for(engine, "rollback")
318
+ def _mark_on_rollback(conn: object) -> None:
319
+ _mark_transaction_ending(conn, outcome="rollback")
320
+
321
+ @event.listens_for(engine, "begin")
322
+ def _finalize_on_next_begin(conn: object) -> None:
323
+ info = _live_connection_info(conn)
324
+ if info is not None:
325
+ _finalize_pending_report(info)
326
+
327
+ @event.listens_for(engine, "checkin")
328
+ def _finalize_on_checkin(dbapi_connection: object, connection_record: object) -> None:
329
+ info = getattr(connection_record, "info", None)
330
+ if info is not None:
331
+ _finalize_pending_report(info)
332
+
333
+ @event.listens_for(engine, "handle_error")
334
+ def _flip_outcome_on_failed_end(exception_context: object) -> None:
335
+ # A DBAPI commit that raises still ends the transaction, but by
336
+ # rollback; the pending report marked at the commit event must not
337
+ # claim a durable commit that never happened.
338
+ connection = getattr(exception_context, "connection", None)
339
+ info = _live_connection_info(connection) if connection is not None else None
340
+ if info is None:
341
+ return
342
+ pending = info.get("sqlite_write_pending_report")
343
+ if isinstance(pending, tuple) and pending[1] == "commit":
344
+ info["sqlite_write_pending_report"] = (pending[0], "commit_failed_rollback", *pending[2:])
345
+
346
+
347
+ def _create_main_engine(url: str) -> AsyncEngine:
348
+ if not _is_sqlite_url(url):
349
+ return _create_postgres_async_engine(
350
+ url,
351
+ role=_PostgresPooledEngineRole.REQUEST_PATH,
352
+ )
353
+
354
+ is_sqlite_memory = _is_sqlite_memory_url(url)
355
+ if is_sqlite_memory:
356
+ main_engine = create_async_engine(
357
+ url,
358
+ echo=False,
359
+ connect_args={"timeout": _SQLITE_BUSY_TIMEOUT_SECONDS},
360
+ )
361
+ else:
362
+ main_engine = create_async_engine(
363
+ url,
364
+ echo=False,
365
+ **_sqlite_file_async_engine_kwargs(),
366
+ )
367
+ _configure_sqlite_engine(main_engine.sync_engine, enable_wal=not is_sqlite_memory)
368
+ return main_engine
369
+
370
+
371
+ engine = _create_main_engine(_database_url)
372
+
373
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
374
+
375
+ _background_engine: AsyncEngine | None = None
376
+ _background_session_factory: async_sessionmaker[AsyncSession] | None = None
377
+ _sqlite_writer_lock: anyio.Lock | None = None
378
+ _sqlite_lifetime_lock: sqlite3.Connection | None = None
379
+ _sqlite_lifetime_lock_path: Path | None = None
380
+
381
+ _T = TypeVar("_T")
382
+
383
+
384
+ class _SqliteBackupCreator(Protocol):
385
+ def __call__(self, source: Path, *, max_files: int) -> Path: ...
386
+
387
+
388
+ def _ensure_sqlite_dir(url: str) -> None:
389
+ sqlite_path = sqlite_db_path_from_url(url)
390
+ if sqlite_path is None:
391
+ return
392
+
393
+ sqlite_path.parent.mkdir(parents=True, exist_ok=True)
394
+
395
+
396
+ def _release_sqlite_lifetime_lock() -> None:
397
+ global _sqlite_lifetime_lock, _sqlite_lifetime_lock_path
398
+ connection = _sqlite_lifetime_lock
399
+ _sqlite_lifetime_lock = None
400
+ _sqlite_lifetime_lock_path = None
401
+ if connection is not None:
402
+ release_sqlite_runstate_lock(connection)
403
+
404
+
405
+ def _acquire_sqlite_lifetime_lock(sqlite_path: Path) -> None:
406
+ global _sqlite_lifetime_lock, _sqlite_lifetime_lock_path
407
+ if _sqlite_lifetime_lock is not None and _sqlite_lifetime_lock_path == sqlite_path:
408
+ return
409
+ _release_sqlite_lifetime_lock()
410
+ connection = acquire_sqlite_runstate_lock(sqlite_path)
411
+ _sqlite_lifetime_lock = connection
412
+ _sqlite_lifetime_lock_path = sqlite_path
413
+
414
+
415
+ def _startup_sqlite_check_mode(raw_mode: str) -> SqliteIntegrityCheckMode | None:
416
+ if raw_mode == "off":
417
+ return None
418
+ return SqliteIntegrityCheckMode(raw_mode)
419
+
420
+
421
+ def _sqlite_startup_check_required(
422
+ sqlite_path: Path,
423
+ *,
424
+ mode: SqliteIntegrityCheckMode,
425
+ previous_state: SqliteRunState | None,
426
+ running_recorded: bool,
427
+ running_identity: SqliteFileIdentity | None,
428
+ ) -> bool:
429
+ """Decide whether this startup has to scan the whole SQLite file.
430
+
431
+ The scan reads every page, so its cost grows with the store and the
432
+ listener cannot bind until it returns. SQLite is already consistent after
433
+ a clean close, so the scan only earns its cost when the previous process
434
+ did not get to record one. Anything other than a recorded clean shutdown
435
+ (a crash, an OOM kill, a power loss, a first run, or an upgrade from a
436
+ build that never wrote the sidecar) still pays for the scan.
437
+ """
438
+ # The prior state is captured before startup marks this process RUNNING.
439
+ # A clean sidecar is not evidence for this startup if that transition was
440
+ # not durably recorded.
441
+ if not running_recorded or previous_state is not SqliteRunState.CLEAN:
442
+ return True
443
+ # Revalidate at the decision seam. A recovery replacement can happen
444
+ # between the earlier sidecar reads and this final branch.
445
+ current_identity = _sqlite_file_identity(sqlite_path)
446
+ if running_identity is None or current_identity is None or running_identity != current_identity:
447
+ return True
448
+ logger.info(
449
+ "Skipping SQLite startup %s after a recorded clean shutdown path=%s",
450
+ integrity_check_pragma_name(mode),
451
+ sqlite_path,
452
+ )
453
+ return False
454
+
455
+
456
+ def _run_startup_sqlite_check(sqlite_path: Path, *, mode: SqliteIntegrityCheckMode) -> IntegrityCheck:
457
+ """Run the startup scan, announcing it so the stall is never unexplained."""
458
+ pragma_name = integrity_check_pragma_name(mode)
459
+ try:
460
+ size_bytes = sqlite_path.stat().st_size
461
+ except OSError:
462
+ size_bytes = 0
463
+ logger.info(
464
+ "Running SQLite startup %s path=%s size_bytes=%s; the listener does not bind until it completes",
465
+ pragma_name,
466
+ sqlite_path,
467
+ size_bytes,
468
+ )
469
+ started_monotonic = time.monotonic()
470
+ integrity = check_sqlite_integrity(sqlite_path, mode=mode)
471
+ elapsed_seconds = time.monotonic() - started_monotonic
472
+ if integrity.ok:
473
+ logger.info(
474
+ "SQLite startup %s passed in %.1fs path=%s",
475
+ pragma_name,
476
+ elapsed_seconds,
477
+ sqlite_path,
478
+ )
479
+ return integrity
480
+
481
+
482
+ def _mark_sqlite_running(sqlite_path: Path) -> bool:
483
+ recorded = write_sqlite_runstate(sqlite_path, SqliteRunState.RUNNING)
484
+ if not recorded:
485
+ logger.warning(
486
+ "Failed to record the SQLite run state path=%s; the next startup will re-run the integrity check",
487
+ sqlite_path,
488
+ )
489
+ return recorded
490
+
491
+
492
+ def mark_sqlite_shutdown_clean() -> None:
493
+ """Record that this process closed the SQLite store cleanly.
494
+
495
+ Call this after the engines are disposed while the process still owns the
496
+ SQLite lifetime lock. The next startup reads it and skips the integrity
497
+ scan, which is what keeps an operator restart from paying a whole-file read
498
+ that grows with the store.
499
+ """
500
+ sqlite_path = sqlite_db_path_from_url(normalize_sqlite_url(_settings.database_url))
501
+ if sqlite_path is None:
502
+ return
503
+ if _sqlite_lifetime_lock is None or _sqlite_lifetime_lock_path != sqlite_path:
504
+ logger.warning(
505
+ "Cannot record a clean SQLite shutdown without the lifetime lock path=%s; "
506
+ "the next startup will run the integrity check",
507
+ sqlite_path,
508
+ )
509
+ return
510
+ try:
511
+ if not write_sqlite_runstate(sqlite_path, SqliteRunState.CLEAN):
512
+ logger.warning(
513
+ "Failed to record a clean SQLite shutdown path=%s; the next startup will run the integrity check",
514
+ sqlite_path,
515
+ )
516
+ finally:
517
+ # The clean transition is the last operation that needs ownership. A
518
+ # failed write remains unknown, but must not leave a process lock held
519
+ # after shutdown has completed.
520
+ _release_sqlite_lifetime_lock()
521
+
522
+
523
+ async def _shielded(awaitable: Awaitable[object]) -> None:
524
+ cancellation = await _await_cleanup_deferring_cancellation(awaitable)
525
+ if cancellation is not None:
526
+ raise cancellation
527
+
528
+
529
+ async def _shielded_bounded(awaitable: Awaitable[object], timeout: float) -> asyncio.Task[object] | None:
530
+ """Observe ``awaitable`` within a bound, shielding it from caller cancellation.
531
+
532
+ Returns ``None`` on successful completion and re-raises task failures.
533
+ Otherwise returns the pending task without cancelling it. The caller owns
534
+ further completion observation and any reclamation; the elapsed deadline
535
+ alone does not show whether the worker is stuck.
536
+ """
537
+ task = asyncio.ensure_future(awaitable)
538
+ loop = asyncio.get_running_loop()
539
+ deadline = loop.time() + timeout
540
+ # The anyio shield keeps a level-cancelled scope from re-raising into
541
+ # every ``await`` (busy-spin); ``asyncio.wait`` removes its callback in a
542
+ # ``finally`` and never cancels ``task``, unlike 3.14's ``asyncio.shield``
543
+ # which leaks a done-callback per cancelled wait. The deadline (not a
544
+ # restarted timeout) keeps the bound exact under repeated edge cancels.
545
+ with anyio.CancelScope(shield=True):
546
+ while not task.done():
547
+ remaining = deadline - loop.time()
548
+ if remaining <= 0:
549
+ break
550
+ try:
551
+ await asyncio.wait({task}, timeout=remaining)
552
+ except asyncio.CancelledError:
553
+ # Teardown runs in ``finally`` blocks: the bound, not the
554
+ # caller's cancellation, decides abandonment.
555
+ continue
556
+ if task.done():
557
+ task.result()
558
+ return None
559
+ return task
560
+
561
+
562
+ def _sqlite_uri_mode_active(url: Any) -> bool:
563
+ """Whether the pysqlite/aiosqlite dialect will connect in URI mode.
564
+
565
+ Mirrors the dialect's ``create_connect_args``: URI mode is enabled only
566
+ when the URL query carries a ``uri`` value that coerces to true.
567
+ """
568
+ query = getattr(url, "query", None)
569
+ if query is None:
570
+ return False
571
+ try:
572
+ value = query.get("uri")
573
+ except Exception:
574
+ return False
575
+ values = value if isinstance(value, (tuple, list)) else (value,)
576
+ for item in values:
577
+ if item is None:
578
+ continue
579
+ try:
580
+ if sqlalchemy_util.asbool(str(item)):
581
+ return True
582
+ except Exception:
583
+ # An unrecognized value would fail at connect time anyway;
584
+ # classify conservatively as file-backed (bounded).
585
+ continue
586
+ return False
587
+
588
+
589
+ def _session_teardown_bound_seconds(session: AsyncSession) -> float | None:
590
+ """Teardown deadline for this session, or None for the unbounded path.
591
+
592
+ Only file-backed SQLite gets a bound: its single writer slot turns a
593
+ wedged teardown into a database-wide write stall (issue #1682).
594
+ PostgreSQL teardown semantics are deliberately untouched, and in-memory
595
+ SQLite shares one StaticPool connection with the whole process — the
596
+ reclaim's invalidation would destroy the entire database (the
597
+ database-backends spec requires preserving shared in-memory state), and
598
+ with a single shared connection there is no cross-connection writer
599
+ contention to starve in the first place.
600
+ """
601
+ try:
602
+ bind = session.get_bind()
603
+ except Exception:
604
+ return None
605
+ if getattr(getattr(bind, "dialect", None), "name", None) != "sqlite":
606
+ return None
607
+ url = getattr(bind, "url", None)
608
+ if url is not None:
609
+ database = getattr(url, "database", None)
610
+ if not database:
611
+ return None
612
+ database_text = str(database)
613
+ if database_text == ":memory:":
614
+ return None
615
+ # SQLite URI forms (``sqlite:///file:name?mode=memory&cache=shared&uri=true``)
616
+ # are in-memory only when the pysqlite/aiosqlite dialect actually
617
+ # passes the database string to the driver as a URI, which it does
618
+ # only when the URL query carries a truthy ``uri`` — and SQLite itself
619
+ # parses a filename as a URI only when it starts with ``file:``.
620
+ # Without ``uri=true``, ``file:name?mode=memory`` is a *file-backed*
621
+ # database whose filename literally contains those characters, so it
622
+ # must keep the bounded teardown.
623
+ if _sqlite_uri_mode_active(url) and database_text.startswith("file:"):
624
+ # ``mode=memory`` normally rides the parsed URL's query; it only
625
+ # appears inside ``url.database`` when the URL escaped the query
626
+ # into the database portion.
627
+ if ":memory:" in database_text or "mode=memory" in database_text:
628
+ return None
629
+ query = getattr(url, "query", None)
630
+ if query is not None:
631
+ try:
632
+ mode = query.get("mode")
633
+ except Exception:
634
+ mode = None
635
+ modes = mode if isinstance(mode, (tuple, list)) else (mode,)
636
+ if any(str(value).lower() == "memory" for value in modes if value is not None):
637
+ return None
638
+ return _SQLITE_TEARDOWN_TIMEOUT_SECONDS
639
+
640
+
641
+ def _session_is_teardown_wedged(session: AsyncSession) -> bool:
642
+ try:
643
+ return bool(session.info.get(_SQLITE_TEARDOWN_WEDGED_INFO_KEY))
644
+ except Exception:
645
+ return False
646
+
647
+
648
+ def _session_sync_connections(session: AsyncSession) -> tuple[Connection, ...]:
649
+ """Best-effort snapshot of the sync Connections held by the session's transaction.
650
+
651
+ Captured before a teardown attempt so a wedged rollback can be attributed
652
+ and its connection reclaimed. Diagnostics only — never raises.
653
+ """
654
+ try:
655
+ transaction = session.sync_session.get_transaction()
656
+ if transaction is None:
657
+ return ()
658
+ connections = getattr(transaction, "_connections", None)
659
+ if not isinstance(connections, dict):
660
+ return ()
661
+ # The transaction tracks each Connection under two keys (the
662
+ # Connection itself and its Engine); deduplicate by identity.
663
+ unique: dict[int, Connection] = {}
664
+ for value in connections.values():
665
+ if isinstance(value, tuple) and value and isinstance(value[0], Connection):
666
+ unique[id(value[0])] = value[0]
667
+ return tuple(unique.values())
668
+ except Exception:
669
+ return ()
670
+
671
+
672
+ def _sqlite_watchdog_identifiers(connection: Connection) -> str:
673
+ """Render the long-write watchdog's identifiers for the wedged connection.
674
+
675
+ Invalidation prevents the connection from ever reaching the watchdog's
676
+ deferred report (next begin / pool checkin), so the reclaim log carries
677
+ the same attribution instead.
678
+ """
679
+ try:
680
+ info = connection.info
681
+ started_at = info.get("sqlite_write_started_at")
682
+ first_statement = info.get("sqlite_first_write_statement")
683
+ last_statement = info.get("sqlite_last_write_statement")
684
+ task_name = info.get("sqlite_write_task")
685
+ if started_at is None:
686
+ # The watchdog's commit/rollback listener already moved the
687
+ # identifiers into the deferred report — the wedge happened inside
688
+ # the transaction-ending call itself, exactly the issue #1682
689
+ # shape.
690
+ pending = info.get("sqlite_write_pending_report")
691
+ if isinstance(pending, tuple) and len(pending) == 5:
692
+ started_at, _, first_statement, last_statement, task_name = pending
693
+ held = f"{time.monotonic() - started_at:.1f}" if isinstance(started_at, float) else "unknown"
694
+ return (
695
+ f"write_held_seconds={held} write_task={task_name!r} "
696
+ f"first_statement={first_statement!r} last_statement={last_statement!r}"
697
+ )
698
+ except Exception:
699
+ return "write_held_seconds=unknown"
700
+
701
+
702
+ async def _teardown_completed_after_bound(abandoned: asyncio.Task[object]) -> bool:
703
+ """Observe successful completion during grace before reclaiming the session.
704
+
705
+ A worker can finish while its loop callbacks are delayed. Failure or
706
+ cancellation still requires reclamation using the captured connections:
707
+ SQLAlchemy may clear ``session._transaction`` before releasing them all.
708
+ """
709
+ try:
710
+ pending = await _shielded_bounded(abandoned, _SQLITE_TEARDOWN_COMPLETION_GRACE_SECONDS)
711
+ except (Exception, asyncio.CancelledError):
712
+ return False
713
+ return pending is None
714
+
715
+
716
+ async def _reclaim_wedged_sqlite_session(
717
+ session: AsyncSession,
718
+ abandoned: asyncio.Task[object],
719
+ connections: tuple[Connection, ...],
720
+ *,
721
+ phase: str,
722
+ elapsed_seconds: float,
723
+ ) -> None:
724
+ """Fence the session and attempt to reclaim its captured open connections.
725
+
726
+ Interrupting the driver and invalidating the connection can release a
727
+ blocked SQLite writer. Each attempt can fail, so retain ownership of the
728
+ original teardown and report cleanup failures without inferring a
729
+ permanent lock from an exception.
730
+ """
731
+ try:
732
+ session.info[_SQLITE_TEARDOWN_WEDGED_INFO_KEY] = True
733
+ except Exception:
734
+ logger.exception("Failed to fence a wedged SQLite session during teardown reclaim")
735
+ # Own the abandoned teardown before this coroutine's first await: if
736
+ # close_db runs concurrently with the reclaim, it must already see the
737
+ # pending task in the registry instead of returning while the rollback is
738
+ # still pending. The completion callbacks are attached only after the
739
+ # reclamation attempts below, so the deferred bookkeeping close follows
740
+ # those attempts and completion of the original teardown.
741
+ _wedged_teardown_cleanup_tasks.add(abandoned)
742
+ for connection in connections:
743
+ if connection.closed:
744
+ # The teardown that outlived the bound got far enough to close this
745
+ # connection before it failed or was cancelled inside grace.
746
+ # Skip the closed handle, but still report the teardown failure
747
+ # that ``_finish_abandoned_teardown`` otherwise consumes.
748
+ failure = abandoned.exception() if abandoned.done() and not abandoned.cancelled() else None
749
+ if failure is not None:
750
+ logger.warning(
751
+ "sqlite_wedged_teardown phase=%s bound_seconds=%.1f elapsed_seconds=%.1f; the %s failed "
752
+ "after releasing its connection; skipping the closed handle: %r",
753
+ phase,
754
+ _SQLITE_TEARDOWN_TIMEOUT_SECONDS,
755
+ elapsed_seconds,
756
+ phase,
757
+ failure,
758
+ )
759
+ else:
760
+ logger.debug(
761
+ "sqlite_wedged_teardown phase=%s; the abandoned %s already closed its connection; "
762
+ "nothing to reclaim",
763
+ phase,
764
+ phase,
765
+ )
766
+ continue
767
+ logger.warning(
768
+ "sqlite_wedged_teardown phase=%s bound_seconds=%.1f elapsed_seconds=%.1f %s; attempting "
769
+ "connection interruption and invalidation to release the writer slot (issue #1682)",
770
+ phase,
771
+ _SQLITE_TEARDOWN_TIMEOUT_SECONDS,
772
+ elapsed_seconds,
773
+ _sqlite_watchdog_identifiers(connection),
774
+ )
775
+ try:
776
+ driver = connection.connection.driver_connection
777
+ if driver is not None:
778
+ # aiosqlite's ``interrupt`` runs sqlite3_interrupt inline on
779
+ # this task — it never enters the (wedged) worker queue. In
780
+ # the pinned aiosqlite (0.22.x) it is a coroutine function;
781
+ # await the result only when it is awaitable so a driver that
782
+ # makes ``interrupt`` synchronous keeps working.
783
+ result = driver.interrupt()
784
+ if inspect.isawaitable(result):
785
+ await result
786
+ except Exception:
787
+ logger.warning(
788
+ "Interrupting a wedged SQLite connection failed; connection invalidation will still be attempted",
789
+ exc_info=True,
790
+ )
791
+ try:
792
+ connection.invalidate()
793
+ except Exception:
794
+ # Report failed reclamation even though the owned teardown may
795
+ # still release the connection later.
796
+ logger.warning(
797
+ "Invalidating a wedged SQLite connection failed; writer-slot release is unconfirmed (issue #1981)",
798
+ exc_info=True,
799
+ )
800
+ if not connections:
801
+ logger.warning(
802
+ "sqlite_wedged_teardown phase=%s bound_seconds=%.1f elapsed_seconds=%.1f; no captured connection "
803
+ "to reclaim; tracking the abandoned %s (issue #1682)",
804
+ phase,
805
+ _SQLITE_TEARDOWN_TIMEOUT_SECONDS,
806
+ elapsed_seconds,
807
+ phase,
808
+ )
809
+ # The abandoned teardown is owned until completion (registered above, so
810
+ # close_db drains it and shutdown waits for — or boundedly abandons — the
811
+ # reclaimed rollback/close instead of returning while it is still
812
+ # pending). The discard callback is registered first so that when the task
813
+ # completes during the drain, deregistration happens before
814
+ # _finish_abandoned_teardown registers the follow-up bookkeeping close.
815
+ abandoned.add_done_callback(_wedged_teardown_cleanup_tasks.discard)
816
+ abandoned.add_done_callback(lambda task: _finish_abandoned_teardown(session, task, phase=phase))
817
+
818
+
819
+ def _finish_abandoned_teardown(session: AsyncSession, task: asyncio.Task[object], *, phase: str) -> None:
820
+ if not task.cancelled():
821
+ # The wedged teardown resuming into an interrupted/invalidated
822
+ # connection is expected to error; consume it so the abandoned task
823
+ # never logs "exception was never retrieved".
824
+ task.exception()
825
+ logger.info("SQLite teardown task finished phase=%s", phase)
826
+ if phase != "rollback":
827
+ return
828
+
829
+ # The session was abandoned before ``close`` ran. Now that no other
830
+ # coroutine can be driving it, finish closing it for bookkeeping and any
831
+ # connection cleanup still needed after the reclamation attempts.
832
+ async def _close_late() -> None:
833
+ try:
834
+ await asyncio.wait_for(session.close(), timeout=_SQLITE_TEARDOWN_TIMEOUT_SECONDS)
835
+ except BaseException:
836
+ logger.debug("Late close of a wedged SQLite session failed", exc_info=True)
837
+
838
+ try:
839
+ cleanup_task = asyncio.get_running_loop().create_task(_close_late())
840
+ except RuntimeError:
841
+ # Event loop already gone; no follow-up task can be scheduled.
842
+ return
843
+ # Own the task until completion: close_db drains it so shutdown cannot
844
+ # skip the promised bookkeeping close or leave a pending-task warning.
845
+ _wedged_teardown_cleanup_tasks.add(cleanup_task)
846
+ cleanup_task.add_done_callback(_wedged_teardown_cleanup_tasks.discard)
847
+
848
+
849
+ async def _finish_bounded_teardown(
850
+ session: AsyncSession,
851
+ abandoned: asyncio.Task[object],
852
+ held_connections: tuple[Connection, ...],
853
+ *,
854
+ phase: str,
855
+ elapsed_seconds: float,
856
+ ) -> None:
857
+ """Observe successful completion in grace or retain reclamation ownership."""
858
+ if await _teardown_completed_after_bound(abandoned):
859
+ logger.warning(
860
+ "sqlite_teardown_bound_elapsed_but_completed phase=%s bound_seconds=%.1f elapsed_seconds=%.1f; "
861
+ "%s completed successfully during completion grace; skipping reclamation (issue #2029)",
862
+ phase,
863
+ _SQLITE_TEARDOWN_TIMEOUT_SECONDS,
864
+ elapsed_seconds,
865
+ phase,
866
+ )
867
+ return
868
+ await _reclaim_wedged_sqlite_session(
869
+ session,
870
+ abandoned,
871
+ held_connections,
872
+ phase=phase,
873
+ elapsed_seconds=elapsed_seconds,
874
+ )
875
+
876
+
877
+ async def _safe_rollback(session: AsyncSession) -> None:
878
+ if not session.in_transaction():
879
+ return
880
+ if _session_is_teardown_wedged(session):
881
+ # A previous bounded teardown abandoned a wedged rollback; the
882
+ # abandoned greenlet may still resume, so never drive this session
883
+ # concurrently. The tracked cleanup now owns the connection.
884
+ return
885
+ bound = _session_teardown_bound_seconds(session)
886
+ if bound is None:
887
+ try:
888
+ await _shielded(session.rollback())
889
+ except BaseException:
890
+ return
891
+ return
892
+ held_connections = _session_sync_connections(session)
893
+ started = asyncio.get_running_loop().time()
894
+ try:
895
+ abandoned = await _shielded_bounded(session.rollback(), bound)
896
+ except BaseException:
897
+ return
898
+ if abandoned is not None:
899
+ await _finish_bounded_teardown(
900
+ session,
901
+ abandoned,
902
+ held_connections,
903
+ phase="rollback",
904
+ elapsed_seconds=asyncio.get_running_loop().time() - started,
905
+ )
906
+
907
+
908
+ async def _safe_close(session: AsyncSession) -> None:
909
+ if _session_is_teardown_wedged(session):
910
+ # Deferred cleanup owns the session now; see _finish_abandoned_teardown.
911
+ return
912
+ bound = _session_teardown_bound_seconds(session)
913
+ if bound is None:
914
+ try:
915
+ await _shielded(session.close())
916
+ except BaseException:
917
+ return
918
+ return
919
+ held_connections = _session_sync_connections(session)
920
+ started = asyncio.get_running_loop().time()
921
+ try:
922
+ abandoned = await _shielded_bounded(session.close(), bound)
923
+ except BaseException:
924
+ return
925
+ if abandoned is not None:
926
+ await _finish_bounded_teardown(
927
+ session,
928
+ abandoned,
929
+ held_connections,
930
+ phase="close",
931
+ elapsed_seconds=asyncio.get_running_loop().time() - started,
932
+ )
933
+
934
+
935
+ async def close_session(session: AsyncSession) -> None:
936
+ async def _close() -> None:
937
+ if session.in_transaction():
938
+ await _safe_rollback(session)
939
+ await _safe_close(session)
940
+
941
+ await _shielded(_close())
942
+
943
+
944
+ def detach_session_objects(session: AsyncSession) -> None:
945
+ """Detach loaded ORM rows that must outlive this session boundary."""
946
+ session.expunge_all()
947
+
948
+
949
+ def _load_migration_entrypoints() -> tuple[
950
+ Callable[[str], "MigrationState"],
951
+ Callable[[str], Awaitable["MigrationRunResult"]],
952
+ Callable[[str], tuple[str, ...]],
953
+ ]:
954
+ from app.db.migrate import check_schema_drift, inspect_migration_state, run_startup_migrations
955
+
956
+ return inspect_migration_state, run_startup_migrations, check_schema_drift
957
+
958
+
959
+ def _load_sqlite_backup_creator() -> _SqliteBackupCreator:
960
+ from app.db.backup import create_sqlite_pre_migration_backup
961
+
962
+ return create_sqlite_pre_migration_backup
963
+
964
+
965
+ def init_background_db(url: str | None = None) -> None:
966
+ """Initialize a separate DB engine for background tasks.
967
+
968
+ The background engine isolates background-task checkouts from the request
969
+ pool; its pool sizing always derives from ``database_pool_size`` /
970
+ ``database_max_overflow``.
971
+
972
+ Args:
973
+ url: Database URL. If None, uses settings.database_url.
974
+ """
975
+ global _background_engine, _background_session_factory
976
+ db_url = normalize_sqlite_url(url or _settings.database_url)
977
+
978
+ if _is_sqlite_url(db_url):
979
+ is_sqlite_memory = _is_sqlite_memory_url(db_url)
980
+ if is_sqlite_memory:
981
+ # Reuse the main engine for in-memory SQLite — creating a second
982
+ # engine would open a separate, empty in-memory database with no
983
+ # schema, causing "no such table" errors in background tasks.
984
+ _background_engine = engine
985
+ _background_session_factory = SessionLocal
986
+ return
987
+ _background_engine = create_async_engine(
988
+ db_url,
989
+ echo=False,
990
+ **_sqlite_file_async_engine_kwargs(),
991
+ )
992
+ _configure_sqlite_engine(_background_engine.sync_engine, enable_wal=not is_sqlite_memory)
993
+ else:
994
+ _background_engine = _create_postgres_async_engine(
995
+ db_url,
996
+ role=_PostgresPooledEngineRole.BACKGROUND_TASK,
997
+ )
998
+
999
+ _background_session_factory = async_sessionmaker(_background_engine, expire_on_commit=False, class_=AsyncSession)
1000
+
1001
+
1002
+ @asynccontextmanager
1003
+ async def get_background_session() -> AsyncIterator[AsyncSession]:
1004
+ """Session provider for background tasks, schedulers, and auth dependencies.
1005
+
1006
+ Uses the separate background pool if initialized, otherwise falls back to main pool.
1007
+ """
1008
+ factory = _background_session_factory or SessionLocal
1009
+ session = factory()
1010
+ try:
1011
+ yield session
1012
+ except BaseException:
1013
+ await _safe_rollback(session)
1014
+ raise
1015
+ finally:
1016
+ await close_session(session)
1017
+
1018
+
1019
+ async def relax_commit_durability(session: AsyncSession) -> None:
1020
+ """Relax commit durability for the current telemetry write transaction.
1021
+
1022
+ High-frequency append-only telemetry writes (request-log inserts, usage
1023
+ history appends) dominate the slow-query profile on PostgreSQL because
1024
+ every commit waits for a WAL fsync. Those rows are pure observability:
1025
+ losing the final unflushed WAL window (bounded by three times
1026
+ ``wal_writer_delay`` — up to ~600 ms at the default 200 ms setting) keeps
1027
+ accounting semantics identical. For such transactions this helper emits
1028
+ ``SET LOCAL synchronous_commit = off`` so the commit returns without
1029
+ waiting for the WAL flush.
1030
+
1031
+ ``SET LOCAL`` is transaction-scoped: PostgreSQL reverts it automatically
1032
+ at COMMIT/ROLLBACK, so nothing leaks onto the pooled connection. Executed
1033
+ outside a transaction, PostgreSQL merely emits a WARNING and the setting
1034
+ never applies — calling this through an ``AsyncSession`` is what makes it
1035
+ safe, because session autobegin opens the write transaction at this very
1036
+ statement when none is open yet.
1037
+
1038
+ No-op on SQLite (durability there is governed by ``PRAGMA synchronous``).
1039
+ MUST NOT be used for configuration writes (accounts, API keys, settings,
1040
+ limits management) or for API-key usage-reservation accounting (creation,
1041
+ settlement, stale release): on external/HA PostgreSQL a server failover
1042
+ does not kill in-flight application requests, so an acked-but-lost
1043
+ reservation commit would desynchronize the accounting ledger from
1044
+ requests that still complete. Those paths keep full durability.
1045
+ """
1046
+ bind = session.get_bind()
1047
+ if bind is None or bind.dialect.name != "postgresql":
1048
+ return
1049
+ await session.execute(text("SET LOCAL synchronous_commit = off"))
1050
+
1051
+
1052
+ @asynccontextmanager
1053
+ async def sqlite_writer_section() -> AsyncIterator[None]:
1054
+ """Serialize local SQLite write transactions without throttling upstream work."""
1055
+ global _sqlite_writer_lock
1056
+ database_url = normalize_sqlite_url(_settings.database_url)
1057
+ if not _is_sqlite_url(database_url) or _is_sqlite_memory_url(database_url):
1058
+ yield
1059
+ return
1060
+ if _sqlite_writer_lock is None:
1061
+ _sqlite_writer_lock = anyio.Lock()
1062
+ async with _sqlite_writer_lock:
1063
+ yield
1064
+
1065
+
1066
+ async def get_session() -> AsyncIterator[AsyncSession]:
1067
+ session = SessionLocal()
1068
+ try:
1069
+ yield session
1070
+ except BaseException:
1071
+ await _safe_rollback(session)
1072
+ raise
1073
+ finally:
1074
+ await close_session(session)
1075
+
1076
+
1077
+ async def _init_db() -> None:
1078
+ database_url = normalize_sqlite_url(_settings.database_url)
1079
+ _ensure_sqlite_dir(database_url)
1080
+ sqlite_path = sqlite_db_path_from_url(database_url)
1081
+ if sqlite_path is not None:
1082
+ # Read the prior transition first, then fence this process as RUNNING
1083
+ # before deciding whether a prior CLEAN record permits skipping the
1084
+ # startup scan. A failed startup therefore leaves RUNNING where the
1085
+ # sidecar can be written instead of resurrecting stale CLEAN state.
1086
+ previous_record = read_sqlite_runstate_record(sqlite_path)
1087
+ previous_state = previous_record.state if previous_record is not None else None
1088
+ previous_clean_identity = (
1089
+ previous_record.identity
1090
+ if previous_record is not None and previous_record.state is SqliteRunState.CLEAN
1091
+ else None
1092
+ )
1093
+ running_transition_error: SqliteRunStateDurabilityError | None = None
1094
+ try:
1095
+ running_recorded = _mark_sqlite_running(sqlite_path)
1096
+ except SqliteRunStateDurabilityError as exc:
1097
+ # A failed write can safely continue only when its old marker was
1098
+ # durably removed. If that invalidation cannot be proven, still
1099
+ # run the configured check, then stop before migrations or serving
1100
+ # instead of allowing a stale CLEAN record to influence startup.
1101
+ running_recorded = False
1102
+ running_transition_error = exc
1103
+ logger.error(
1104
+ "Could not durably invalidate the SQLite run state path=%s; startup will fail closed",
1105
+ sqlite_path,
1106
+ exc_info=exc,
1107
+ )
1108
+ running_record = read_sqlite_runstate_record(sqlite_path) if running_recorded else None
1109
+ running_identity = running_record.identity if running_record is not None else None
1110
+ current_identity = _sqlite_file_identity(sqlite_path)
1111
+ if previous_state is SqliteRunState.CLEAN and (
1112
+ previous_clean_identity is None
1113
+ or running_identity is None
1114
+ or current_identity is None
1115
+ or previous_clean_identity != running_identity
1116
+ or running_identity != current_identity
1117
+ ):
1118
+ # The database may have been replaced on either side of the
1119
+ # running fence. The prior clean identity no longer describes the
1120
+ # file that this startup is about to use, so force the scan.
1121
+ previous_state = None
1122
+ check_mode = _startup_sqlite_check_mode(_settings.database_sqlite_startup_check_mode)
1123
+ if check_mode is not None and _sqlite_startup_check_required(
1124
+ sqlite_path,
1125
+ mode=check_mode,
1126
+ previous_state=previous_state,
1127
+ running_recorded=running_recorded,
1128
+ running_identity=running_identity,
1129
+ ):
1130
+ integrity = _run_startup_sqlite_check(sqlite_path, mode=check_mode)
1131
+ if not integrity.ok:
1132
+ details = integrity.details or "unknown error"
1133
+ pragma_name = integrity_check_pragma_name(check_mode)
1134
+ logger.error(
1135
+ "SQLite %s failed path=%s details=%s",
1136
+ pragma_name,
1137
+ sqlite_path,
1138
+ details,
1139
+ )
1140
+ if "locked" in details.lower():
1141
+ message = (
1142
+ f"SQLite {pragma_name} failed for {sqlite_path} ({details}). "
1143
+ "Another instance may be running. Stop it and retry."
1144
+ )
1145
+ else:
1146
+ message = (
1147
+ f"SQLite {pragma_name} failed for {sqlite_path} ({details}). "
1148
+ "The database appears corrupted or the filesystem is unhealthy. "
1149
+ "Stop the app and run "
1150
+ f'`python -m app.db.recover --db "{sqlite_path}" --replace` '
1151
+ "or restore a backup from the same directory."
1152
+ )
1153
+ raise RuntimeError(message)
1154
+ if running_transition_error is not None:
1155
+ raise running_transition_error
1156
+ try:
1157
+ inspect_migration_state, run_startup_migrations, check_schema_drift = _load_migration_entrypoints()
1158
+ except ModuleNotFoundError as exc:
1159
+ if exc.name != "app.db.migrate":
1160
+ raise
1161
+ logger.exception("Failed to import migration entrypoint module=app.db.migrate")
1162
+ raise RuntimeError("Database migration entrypoint app.db.migrate is unavailable") from exc
1163
+ except ImportError as exc:
1164
+ logger.exception("Failed to import database migration entrypoints from app.db.migrate")
1165
+ raise RuntimeError("Database migration entrypoint app.db.migrate is invalid") from exc
1166
+
1167
+ if not _settings.database_migrate_on_startup:
1168
+ migration_state = await to_thread.run_sync(
1169
+ lambda: inspect_migration_state(database_url),
1170
+ )
1171
+ if migration_state.needs_upgrade:
1172
+ current_revision = migration_state.current_revision or "none"
1173
+ if migration_state.is_ahead:
1174
+ unknown_revisions = ",".join(migration_state.unknown_revisions)
1175
+ message = (
1176
+ "Startup database migration is disabled and database schema revision(s) "
1177
+ f"{unknown_revisions} are not known to this build (head={migration_state.head_revision}). "
1178
+ "The schema was likely migrated by a newer version; deploy a matching or newer image, "
1179
+ "or run an Alembic downgrade to a revision this build knows."
1180
+ )
1181
+ else:
1182
+ message = (
1183
+ "Startup database migration is disabled but database schema is behind Alembic head "
1184
+ f"(current={current_revision}, head={migration_state.head_revision}). "
1185
+ "Run the dedicated migration job or `python -m app.db.migrate upgrade` before starting the app."
1186
+ )
1187
+ logger.error(message)
1188
+ raise RuntimeError(message)
1189
+
1190
+ logger.info("Startup database migration is disabled and database schema is current")
1191
+ return
1192
+
1193
+ if sqlite_path is not None and _settings.database_sqlite_pre_migrate_backup_enabled and sqlite_path.exists():
1194
+ migration_state = await to_thread.run_sync(
1195
+ lambda: inspect_migration_state(database_url),
1196
+ )
1197
+ if migration_state.needs_upgrade:
1198
+ try:
1199
+ create_sqlite_pre_migration_backup = _load_sqlite_backup_creator()
1200
+ except ModuleNotFoundError as exc:
1201
+ if exc.name != "app.db.backup":
1202
+ raise
1203
+ logger.exception("Failed to import SQLite backup module=app.db.backup")
1204
+ raise RuntimeError("SQLite backup module app.db.backup is unavailable") from exc
1205
+
1206
+ backup_path = await to_thread.run_sync(
1207
+ lambda: create_sqlite_pre_migration_backup(
1208
+ sqlite_path,
1209
+ max_files=_settings.database_sqlite_pre_migrate_backup_max_files,
1210
+ ),
1211
+ )
1212
+ logger.info(
1213
+ "Created SQLite pre-migration backup path=%s target_revision=%s",
1214
+ backup_path,
1215
+ migration_state.head_revision,
1216
+ )
1217
+
1218
+ try:
1219
+ result = await run_startup_migrations(database_url)
1220
+ if result.bootstrap.stamped_revision is not None:
1221
+ logger.info(
1222
+ "Bootstrapped legacy migrations stamped_revision=%s legacy_rows=%s",
1223
+ result.bootstrap.stamped_revision,
1224
+ result.bootstrap.legacy_row_count,
1225
+ )
1226
+ if result.current_revision is not None:
1227
+ logger.info("Database migration complete revision=%s", result.current_revision)
1228
+ drift = await to_thread.run_sync(lambda: check_schema_drift(database_url))
1229
+ if drift:
1230
+ drift_details = "; ".join(drift)
1231
+ raise RuntimeError(f"Schema drift detected after startup migrations: {drift_details}")
1232
+ except Exception:
1233
+ logger.exception("Failed to apply database migrations")
1234
+ if _settings.database_migrations_fail_fast:
1235
+ raise
1236
+
1237
+
1238
+ async def init_db() -> None:
1239
+ """Initialize the database while fencing one process onto file SQLite."""
1240
+ database_url = normalize_sqlite_url(_settings.database_url)
1241
+ _ensure_sqlite_dir(database_url)
1242
+ sqlite_path = sqlite_db_path_from_url(database_url)
1243
+ if sqlite_path is None:
1244
+ _release_sqlite_lifetime_lock()
1245
+ await _init_db()
1246
+ return
1247
+
1248
+ _acquire_sqlite_lifetime_lock(sqlite_path)
1249
+ try:
1250
+ await _init_db()
1251
+ except BaseException:
1252
+ # A failed startup never owns a live application, so it must not leave
1253
+ # this process's test/reload path holding the sentinel indefinitely.
1254
+ _release_sqlite_lifetime_lock()
1255
+ raise
1256
+
1257
+
1258
+ async def close_db() -> bool:
1259
+ """Dispose database engines and report whether SQLite teardown fully drained."""
1260
+ loop = asyncio.get_running_loop()
1261
+ # A teardown reclaimed as wedged can finish while engine disposal is in
1262
+ # progress and register its bookkeeping close from a done callback. Use
1263
+ # one deadline for both registry snapshots so that late work is included
1264
+ # without allowing shutdown to grow an unbounded second wait.
1265
+ deadline = loop.time() + 2 * _SQLITE_TEARDOWN_TIMEOUT_SECONDS
1266
+ sqlite_teardown_drained = True
1267
+
1268
+ async def _drain_wedged_teardown_registry() -> None:
1269
+ nonlocal sqlite_teardown_drained
1270
+ while _wedged_teardown_cleanup_tasks:
1271
+ remaining = deadline - loop.time()
1272
+ if remaining <= 0:
1273
+ if sqlite_teardown_drained:
1274
+ logger.warning(
1275
+ "close_db abandoned %d still-pending wedged-teardown task(s) after the bounded "
1276
+ "drain; their connections were already reclaimed (issue #1682)",
1277
+ len(_wedged_teardown_cleanup_tasks),
1278
+ )
1279
+ sqlite_teardown_drained = False
1280
+ return
1281
+ await asyncio.wait(tuple(_wedged_teardown_cleanup_tasks), timeout=remaining)
1282
+ # Completion callbacks (deregistration and scheduling of the
1283
+ # deferred bookkeeping close) run via call_soon; yield once so
1284
+ # the registry reflects them before the next stability check.
1285
+ await asyncio.sleep(0)
1286
+
1287
+ # Drain work already registered before disposal, then give disposal a
1288
+ # chance to schedule late cleanup before taking the final bounded snapshot.
1289
+ await _drain_wedged_teardown_registry()
1290
+ await engine.dispose()
1291
+ if _background_engine is not None:
1292
+ await _background_engine.dispose()
1293
+ await asyncio.sleep(0)
1294
+ await _drain_wedged_teardown_registry()
1295
+ return sqlite_teardown_drained