codexLB-up 1.25.0b10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (827) hide show
  1. app/__init__.py +10 -0
  2. app/admin_cli.py +494 -0
  3. app/cli.py +339 -0
  4. app/codex_sessions_retag.py +487 -0
  5. app/core/__init__.py +0 -0
  6. app/core/audit/__init__.py +0 -0
  7. app/core/audit/service.py +216 -0
  8. app/core/audit/types.py +94 -0
  9. app/core/auth/__init__.py +236 -0
  10. app/core/auth/api_key_cache.py +65 -0
  11. app/core/auth/dashboard_access.py +429 -0
  12. app/core/auth/dashboard_mode.py +171 -0
  13. app/core/auth/dashboard_session_ttl.py +74 -0
  14. app/core/auth/dashboard_users_cache.py +120 -0
  15. app/core/auth/dependencies.py +710 -0
  16. app/core/auth/external_identity.py +78 -0
  17. app/core/auth/guardian.py +374 -0
  18. app/core/auth/models.py +49 -0
  19. app/core/auth/providers/__init__.py +129 -0
  20. app/core/auth/providers/oidc.py +838 -0
  21. app/core/auth/providers/registry.py +118 -0
  22. app/core/auth/refresh.py +394 -0
  23. app/core/auth/step_up.py +117 -0
  24. app/core/auth/totp.py +81 -0
  25. app/core/balancer/__init__.py +71 -0
  26. app/core/balancer/logic.py +1612 -0
  27. app/core/balancer/rendezvous_hash.py +21 -0
  28. app/core/balancer/types.py +21 -0
  29. app/core/bootstrap.py +172 -0
  30. app/core/cache/invalidation.py +426 -0
  31. app/core/clients/__init__.py +0 -0
  32. app/core/clients/codex.py +996 -0
  33. app/core/clients/codex_version.py +187 -0
  34. app/core/clients/codex_version_snapshot.py +2 -0
  35. app/core/clients/files.py +534 -0
  36. app/core/clients/headers.py +29 -0
  37. app/core/clients/http.py +511 -0
  38. app/core/clients/model_fetcher.py +276 -0
  39. app/core/clients/native_egress.py +1304 -0
  40. app/core/clients/oauth.py +477 -0
  41. app/core/clients/proxy.py +6089 -0
  42. app/core/clients/proxy_websocket.py +1411 -0
  43. app/core/clients/rate_limit_reset_credits.py +412 -0
  44. app/core/clients/stream_errors.py +12 -0
  45. app/core/clients/thread_cache_identity.py +347 -0
  46. app/core/clients/usage.py +467 -0
  47. app/core/clock.py +170 -0
  48. app/core/config/__init__.py +0 -0
  49. app/core/config/background_jobs.py +73 -0
  50. app/core/config/context_window_overrides.py +166 -0
  51. app/core/config/dashboard_overrides.py +145 -0
  52. app/core/config/inheritable.py +47 -0
  53. app/core/config/key_fingerprint.py +115 -0
  54. app/core/config/settings.py +872 -0
  55. app/core/config/settings_cache.py +100 -0
  56. app/core/config/spool_retention.py +217 -0
  57. app/core/config/tiers.py +174 -0
  58. app/core/conversation_archive.py +568 -0
  59. app/core/crypto.py +37 -0
  60. app/core/draining.py +11 -0
  61. app/core/errors.py +365 -0
  62. app/core/exceptions.py +155 -0
  63. app/core/handlers/__init__.py +3 -0
  64. app/core/handlers/exceptions.py +478 -0
  65. app/core/http_protocol.py +176 -0
  66. app/core/http_protocol_httptools.py +204 -0
  67. app/core/ingress_limits.py +17 -0
  68. app/core/metadata_store.py +18 -0
  69. app/core/metrics/__init__.py +0 -0
  70. app/core/metrics/middleware.py +74 -0
  71. app/core/metrics/prometheus.py +604 -0
  72. app/core/middleware/__init__.py +25 -0
  73. app/core/middleware/api_firewall.py +111 -0
  74. app/core/middleware/app_version.py +37 -0
  75. app/core/middleware/dashboard_auth_proxy.py +55 -0
  76. app/core/middleware/dashboard_csrf.py +177 -0
  77. app/core/middleware/dashboard_gzip.py +41 -0
  78. app/core/middleware/dashboard_overrides.py +59 -0
  79. app/core/middleware/firewall_cache.py +91 -0
  80. app/core/middleware/inflight.py +126 -0
  81. app/core/middleware/multipart_content_encoding.py +129 -0
  82. app/core/middleware/path_rewrite.py +148 -0
  83. app/core/middleware/request_body_limit.py +196 -0
  84. app/core/middleware/request_decompression.py +209 -0
  85. app/core/middleware/request_id.py +72 -0
  86. app/core/middleware/required_capability_http.py +116 -0
  87. app/core/middleware/trusted_proxy_headers.py +36 -0
  88. app/core/multipart.py +237 -0
  89. app/core/multipart_fields.py +91 -0
  90. app/core/openai/__init__.py +0 -0
  91. app/core/openai/chat_requests.py +493 -0
  92. app/core/openai/chat_responses.py +931 -0
  93. app/core/openai/contracts.py +46 -0
  94. app/core/openai/exceptions.py +24 -0
  95. app/core/openai/host_models.py +14 -0
  96. app/core/openai/images.py +413 -0
  97. app/core/openai/message_coercion.py +376 -0
  98. app/core/openai/model_refresh_scheduler.py +423 -0
  99. app/core/openai/model_registry.py +997 -0
  100. app/core/openai/model_registry_store.py +548 -0
  101. app/core/openai/models.py +172 -0
  102. app/core/openai/parsing.py +97 -0
  103. app/core/openai/requests.py +1980 -0
  104. app/core/openai/strict_schema.py +199 -0
  105. app/core/openai/tool_call_safety.py +99 -0
  106. app/core/openai/v1_requests.py +187 -0
  107. app/core/plan_types.py +90 -0
  108. app/core/prestop.py +202 -0
  109. app/core/rate_limiter/__init__.py +0 -0
  110. app/core/rate_limiter/db_rate_limiter.py +215 -0
  111. app/core/request_locality.py +473 -0
  112. app/core/resilience/__init__.py +0 -0
  113. app/core/resilience/backpressure.py +57 -0
  114. app/core/resilience/bulkhead.py +202 -0
  115. app/core/resilience/circuit_breaker.py +150 -0
  116. app/core/resilience/degradation.py +55 -0
  117. app/core/resilience/loop_lag_monitor.py +60 -0
  118. app/core/resilience/memory_monitor.py +137 -0
  119. app/core/resilience/network_recovery.py +256 -0
  120. app/core/resilience/overload.py +98 -0
  121. app/core/resilience/toggles.py +114 -0
  122. app/core/retention/__init__.py +0 -0
  123. app/core/retention/job.py +240 -0
  124. app/core/retention/scheduler.py +69 -0
  125. app/core/runtime_logging.py +601 -0
  126. app/core/scheduling/__init__.py +0 -0
  127. app/core/scheduling/leader_election.py +993 -0
  128. app/core/scheduling/leader_election_handle.py +25 -0
  129. app/core/server.py +140 -0
  130. app/core/shutdown.py +326 -0
  131. app/core/socket_peer.py +22 -0
  132. app/core/startup.py +41 -0
  133. app/core/timeout_invariants.py +347 -0
  134. app/core/tracing/__init__.py +0 -0
  135. app/core/tracing/otel.py +207 -0
  136. app/core/types.py +6 -0
  137. app/core/upstream_proxy/__init__.py +13 -0
  138. app/core/upstream_proxy/cache.py +130 -0
  139. app/core/upstream_proxy/resolver.py +189 -0
  140. app/core/upstream_proxy/types.py +104 -0
  141. app/core/usage/__init__.py +407 -0
  142. app/core/usage/depletion.py +109 -0
  143. app/core/usage/live_hub.py +41 -0
  144. app/core/usage/live_snapshots.py +174 -0
  145. app/core/usage/logs.py +212 -0
  146. app/core/usage/metadata_scheduler.py +118 -0
  147. app/core/usage/models.py +54 -0
  148. app/core/usage/pricing.py +576 -0
  149. app/core/usage/pricing_catalog.py +241 -0
  150. app/core/usage/pricing_snapshot.json +821 -0
  151. app/core/usage/quota.py +133 -0
  152. app/core/usage/refresh_policy.py +21 -0
  153. app/core/usage/refresh_scheduler.py +713 -0
  154. app/core/usage/reset_credits_refresh_scheduler.py +435 -0
  155. app/core/usage/types.py +168 -0
  156. app/core/utils/__init__.py +0 -0
  157. app/core/utils/json_guards.py +18 -0
  158. app/core/utils/locks.py +33 -0
  159. app/core/utils/masking.py +20 -0
  160. app/core/utils/proxy_env.py +104 -0
  161. app/core/utils/request_id.py +65 -0
  162. app/core/utils/retry.py +77 -0
  163. app/core/utils/shared_future.py +172 -0
  164. app/core/utils/sse.py +286 -0
  165. app/core/utils/time.py +24 -0
  166. app/db/__init__.py +0 -0
  167. app/db/account_identity_lock.py +56 -0
  168. app/db/alembic/__init__.py +1 -0
  169. app/db/alembic/env.py +70 -0
  170. app/db/alembic/revision_ids.py +43 -0
  171. app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
  172. app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
  173. app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
  174. app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
  175. app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
  176. app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
  177. app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
  178. app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
  179. app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
  180. app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
  181. app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
  182. app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
  183. app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
  184. app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
  185. app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
  186. app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
  187. app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
  188. app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
  189. app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
  190. app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
  191. app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
  192. app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
  193. app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
  194. app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
  195. app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
  196. app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
  197. app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
  198. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
  199. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
  200. app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
  201. app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
  202. app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
  203. app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
  204. app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
  205. app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
  206. app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
  207. app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
  208. app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
  209. app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
  210. app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
  211. app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
  212. app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
  213. app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
  214. app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
  215. app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
  216. app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
  217. app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
  218. app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
  219. app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
  220. app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
  221. app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
  222. app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
  223. app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
  224. app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
  225. app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
  226. app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
  227. app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
  228. app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
  229. app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
  230. app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
  231. app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
  232. app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
  233. app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
  234. app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
  235. app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
  236. app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
  237. app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
  238. app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
  239. app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
  240. app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
  241. app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
  242. app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
  243. app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
  244. app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
  245. app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
  246. app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
  247. app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
  248. app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
  249. app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
  250. app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
  251. app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
  252. app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
  253. app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
  254. app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
  255. app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
  256. app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
  257. app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
  258. app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
  259. app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
  260. app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
  261. app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
  262. app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
  263. app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
  264. app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
  265. app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
  266. app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
  267. app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
  268. app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
  269. app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
  270. app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
  271. app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
  272. app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
  273. app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
  274. app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
  275. app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
  276. app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
  277. app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
  278. app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
  279. app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
  280. app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
  281. app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
  282. app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
  283. app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
  284. app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
  285. app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
  286. app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
  287. app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
  288. app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
  289. app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
  290. app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
  291. app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
  292. app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
  293. app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
  294. app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
  295. app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
  296. app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
  297. app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
  298. app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
  299. app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
  300. app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
  301. app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
  302. app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
  303. app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
  304. app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
  305. app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
  306. app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
  307. app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
  308. app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
  309. app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
  310. app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
  311. app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
  312. app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
  313. app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
  314. app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
  315. app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
  316. app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
  317. app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
  318. app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
  319. app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
  320. app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
  321. app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
  322. app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
  323. app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
  324. app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
  325. app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
  326. app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
  327. app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
  328. app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
  329. app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
  330. app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
  331. app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
  332. app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
  333. app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
  334. app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
  335. app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
  336. app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
  337. app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
  338. app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
  339. app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
  340. app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
  341. app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
  342. app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
  343. app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
  344. app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
  345. app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
  346. app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
  347. app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
  348. app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
  349. app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
  350. app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
  351. app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
  352. app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
  353. app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
  354. app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
  355. app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
  356. app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
  357. app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
  358. app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
  359. app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
  360. app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
  361. app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
  362. app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
  363. app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
  364. app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
  365. app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
  366. app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
  367. app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
  368. app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
  369. app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
  370. app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
  371. app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
  372. app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
  373. app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
  374. app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
  375. app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
  376. app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
  377. app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
  378. app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
  379. app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
  380. app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
  381. app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
  382. app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
  383. app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
  384. app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
  385. app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
  386. app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
  387. app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
  388. app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
  389. app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
  390. app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
  391. app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
  392. app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
  393. app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
  394. app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
  395. app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
  396. app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
  397. app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
  398. app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
  399. app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
  400. app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
  401. app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
  402. app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
  403. app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
  404. app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
  405. app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
  406. app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
  407. app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
  408. app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
  409. app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
  410. app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
  411. app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
  412. app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
  413. app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
  414. app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
  415. app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
  416. app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
  417. app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
  418. app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
  419. app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
  420. app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
  421. app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
  422. app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
  423. app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
  424. app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
  425. app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
  426. app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
  427. app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
  428. app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
  429. app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
  430. app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
  431. app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
  432. app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
  433. app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
  434. app/db/alembic/versions/__init__.py +1 -0
  435. app/db/backup.py +64 -0
  436. app/db/migrate.py +1249 -0
  437. app/db/migration_lock.py +160 -0
  438. app/db/migration_url.py +18 -0
  439. app/db/models.py +3100 -0
  440. app/db/recover.py +244 -0
  441. app/db/session.py +1295 -0
  442. app/db/snapshot.py +46 -0
  443. app/db/sqlite_lock_retry.py +190 -0
  444. app/db/sqlite_utils.py +549 -0
  445. app/dependencies.py +462 -0
  446. app/main.py +1240 -0
  447. app/modules/__init__.py +0 -0
  448. app/modules/accounts/__init__.py +0 -0
  449. app/modules/accounts/api.py +426 -0
  450. app/modules/accounts/auth_manager.py +1439 -0
  451. app/modules/accounts/background_repository.py +160 -0
  452. app/modules/accounts/deletion.py +540 -0
  453. app/modules/accounts/mappers.py +654 -0
  454. app/modules/accounts/refresh_claims.py +352 -0
  455. app/modules/accounts/repository.py +1679 -0
  456. app/modules/accounts/schemas.py +308 -0
  457. app/modules/accounts/service.py +809 -0
  458. app/modules/accounts/usage_rollup.py +486 -0
  459. app/modules/accounts/usage_rollup_scheduler.py +75 -0
  460. app/modules/accounts/usage_time_rollup.py +1037 -0
  461. app/modules/accounts/usage_time_rollup_read.py +522 -0
  462. app/modules/api_keys/__init__.py +1 -0
  463. app/modules/api_keys/api.py +344 -0
  464. app/modules/api_keys/last_used_coalescer.py +216 -0
  465. app/modules/api_keys/limit_windows.py +41 -0
  466. app/modules/api_keys/repository.py +1200 -0
  467. app/modules/api_keys/reset_scheduler.py +85 -0
  468. app/modules/api_keys/schemas.py +135 -0
  469. app/modules/api_keys/service.py +2123 -0
  470. app/modules/audit/__init__.py +0 -0
  471. app/modules/audit/api.py +88 -0
  472. app/modules/audit/repository.py +74 -0
  473. app/modules/audit/schemas.py +33 -0
  474. app/modules/audit/service.py +71 -0
  475. app/modules/auth_providers/__init__.py +0 -0
  476. app/modules/auth_providers/api.py +127 -0
  477. app/modules/auth_providers/config.py +343 -0
  478. app/modules/auth_providers/repository.py +101 -0
  479. app/modules/auth_providers/schemas.py +88 -0
  480. app/modules/auth_providers/seed.py +105 -0
  481. app/modules/auth_providers/service.py +289 -0
  482. app/modules/automations/__init__.py +1 -0
  483. app/modules/automations/api.py +369 -0
  484. app/modules/automations/repository.py +1896 -0
  485. app/modules/automations/scheduler.py +107 -0
  486. app/modules/automations/schemas.py +158 -0
  487. app/modules/automations/service.py +2548 -0
  488. app/modules/cache_isolation_probe/__init__.py +0 -0
  489. app/modules/cache_isolation_probe/api.py +188 -0
  490. app/modules/cache_isolation_probe/prefix.py +144 -0
  491. app/modules/cache_isolation_probe/schemas.py +94 -0
  492. app/modules/cache_isolation_probe/sender.py +229 -0
  493. app/modules/cache_isolation_probe/service.py +525 -0
  494. app/modules/conversation_archive/__init__.py +1 -0
  495. app/modules/conversation_archive/api.py +101 -0
  496. app/modules/conversation_archive/schemas.py +28 -0
  497. app/modules/conversation_archive/service.py +165 -0
  498. app/modules/dashboard/__init__.py +1 -0
  499. app/modules/dashboard/api.py +106 -0
  500. app/modules/dashboard/builders.py +107 -0
  501. app/modules/dashboard/repository.py +304 -0
  502. app/modules/dashboard/schemas.py +148 -0
  503. app/modules/dashboard/service.py +534 -0
  504. app/modules/dashboard/timeframes.py +19 -0
  505. app/modules/dashboard/weekly_pace.py +617 -0
  506. app/modules/dashboard_auth/__init__.py +1 -0
  507. app/modules/dashboard_auth/api.py +1344 -0
  508. app/modules/dashboard_auth/oidc_api.py +753 -0
  509. app/modules/dashboard_auth/oidc_flows.py +330 -0
  510. app/modules/dashboard_auth/repository.py +452 -0
  511. app/modules/dashboard_auth/schemas.py +232 -0
  512. app/modules/dashboard_auth/service.py +1384 -0
  513. app/modules/dashboard_roles/__init__.py +0 -0
  514. app/modules/dashboard_roles/api.py +64 -0
  515. app/modules/dashboard_roles/repository.py +54 -0
  516. app/modules/dashboard_roles/schemas.py +29 -0
  517. app/modules/dashboard_roles/seed.py +80 -0
  518. app/modules/dashboard_roles/service.py +142 -0
  519. app/modules/dashboard_users/__init__.py +0 -0
  520. app/modules/dashboard_users/api.py +278 -0
  521. app/modules/dashboard_users/break_glass.py +169 -0
  522. app/modules/dashboard_users/credentials.py +18 -0
  523. app/modules/dashboard_users/identity_resolver.py +665 -0
  524. app/modules/dashboard_users/repository.py +725 -0
  525. app/modules/dashboard_users/schemas.py +117 -0
  526. app/modules/dashboard_users/service.py +1054 -0
  527. app/modules/firewall/__init__.py +1 -0
  528. app/modules/firewall/api.py +78 -0
  529. app/modules/firewall/repository.py +51 -0
  530. app/modules/firewall/schemas.py +28 -0
  531. app/modules/firewall/service.py +93 -0
  532. app/modules/fleet/__init__.py +1 -0
  533. app/modules/fleet/api.py +177 -0
  534. app/modules/fleet/mappers.py +56 -0
  535. app/modules/fleet/observability.py +372 -0
  536. app/modules/fleet/schemas.py +119 -0
  537. app/modules/health/__init__.py +0 -0
  538. app/modules/health/api.py +246 -0
  539. app/modules/health/schemas.py +27 -0
  540. app/modules/limit_warmup/__init__.py +1 -0
  541. app/modules/limit_warmup/repository.py +159 -0
  542. app/modules/limit_warmup/service.py +1001 -0
  543. app/modules/model_sources/__init__.py +1 -0
  544. app/modules/model_sources/api.py +99 -0
  545. app/modules/model_sources/catalog.py +307 -0
  546. app/modules/model_sources/forwarding.py +1435 -0
  547. app/modules/model_sources/projection.py +55 -0
  548. app/modules/model_sources/repository.py +201 -0
  549. app/modules/model_sources/schemas.py +79 -0
  550. app/modules/model_sources/selection.py +166 -0
  551. app/modules/model_sources/service.py +236 -0
  552. app/modules/oauth/__init__.py +0 -0
  553. app/modules/oauth/api.py +101 -0
  554. app/modules/oauth/repository.py +238 -0
  555. app/modules/oauth/schemas.py +49 -0
  556. app/modules/oauth/service.py +1196 -0
  557. app/modules/oauth/templates/oauth_success.html +122 -0
  558. app/modules/proxy/__init__.py +0 -0
  559. app/modules/proxy/_load_balancer/__init__.py +1 -0
  560. app/modules/proxy/_load_balancer/error_rate.py +116 -0
  561. app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
  562. app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
  563. app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
  564. app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
  565. app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
  566. app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
  567. app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
  568. app/modules/proxy/_load_balancer/tunables.py +97 -0
  569. app/modules/proxy/_load_balancer/types.py +96 -0
  570. app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
  571. app/modules/proxy/_service/__init__.py +6 -0
  572. app/modules/proxy/_service/api_key_usage.py +745 -0
  573. app/modules/proxy/_service/clock_budget.py +38 -0
  574. app/modules/proxy/_service/codex_control.py +611 -0
  575. app/modules/proxy/_service/compact.py +2211 -0
  576. app/modules/proxy/_service/file_ops.py +749 -0
  577. app/modules/proxy/_service/http_bridge/__init__.py +3 -0
  578. app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
  579. app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
  580. app/modules/proxy/_service/http_bridge/activity.py +178 -0
  581. app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
  582. app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
  583. app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
  584. app/modules/proxy/_service/http_bridge/protocol.py +167 -0
  585. app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
  586. app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
  587. app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
  588. app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
  589. app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
  590. app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
  591. app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
  592. app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
  593. app/modules/proxy/_service/observability.py +410 -0
  594. app/modules/proxy/_service/rate_limit.py +361 -0
  595. app/modules/proxy/_service/realtime_live.py +655 -0
  596. app/modules/proxy/_service/refresh.py +154 -0
  597. app/modules/proxy/_service/request_log.py +585 -0
  598. app/modules/proxy/_service/response_create.py +930 -0
  599. app/modules/proxy/_service/streaming/__init__.py +3 -0
  600. app/modules/proxy/_service/streaming/helpers.py +1317 -0
  601. app/modules/proxy/_service/streaming/mixin.py +1100 -0
  602. app/modules/proxy/_service/streaming/protocol.py +32 -0
  603. app/modules/proxy/_service/streaming/retry.py +3762 -0
  604. app/modules/proxy/_service/support.py +2122 -0
  605. app/modules/proxy/_service/transcribe.py +434 -0
  606. app/modules/proxy/_service/warmup.py +483 -0
  607. app/modules/proxy/_service/websocket/__init__.py +3 -0
  608. app/modules/proxy/_service/websocket/helpers.py +2360 -0
  609. app/modules/proxy/_service/websocket/mixin.py +7285 -0
  610. app/modules/proxy/_service/websocket/protocol.py +74 -0
  611. app/modules/proxy/account_cache.py +229 -0
  612. app/modules/proxy/account_eligibility.py +87 -0
  613. app/modules/proxy/additional_model_limits.py +40 -0
  614. app/modules/proxy/affinity.py +830 -0
  615. app/modules/proxy/affinity_observation.py +101 -0
  616. app/modules/proxy/api.py +10254 -0
  617. app/modules/proxy/api_key_usage.py +113 -0
  618. app/modules/proxy/cap_partitioning.py +280 -0
  619. app/modules/proxy/capability_lineage.py +65 -0
  620. app/modules/proxy/capability_lineage_repository.py +82 -0
  621. app/modules/proxy/capability_routing.py +253 -0
  622. app/modules/proxy/continuity.py +112 -0
  623. app/modules/proxy/downstream_delivery.py +277 -0
  624. app/modules/proxy/durable_bridge_coordinator.py +1217 -0
  625. app/modules/proxy/durable_bridge_repository.py +4516 -0
  626. app/modules/proxy/durable_bridge_runtime.py +9 -0
  627. app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
  628. app/modules/proxy/fair_share.py +131 -0
  629. app/modules/proxy/file_pin_repository.py +210 -0
  630. app/modules/proxy/helpers.py +439 -0
  631. app/modules/proxy/http_bridge_event_batcher.py +613 -0
  632. app/modules/proxy/http_bridge_forwarding.py +797 -0
  633. app/modules/proxy/http_continuation.py +76 -0
  634. app/modules/proxy/images_observability.py +62 -0
  635. app/modules/proxy/images_service.py +1054 -0
  636. app/modules/proxy/load_balancer.py +3016 -0
  637. app/modules/proxy/rate_limit_cache.py +46 -0
  638. app/modules/proxy/replay_safety.py +1063 -0
  639. app/modules/proxy/repo_bundle.py +31 -0
  640. app/modules/proxy/request_policy.py +1054 -0
  641. app/modules/proxy/ring_membership.py +251 -0
  642. app/modules/proxy/schemas.py +345 -0
  643. app/modules/proxy/selection_errors.py +41 -0
  644. app/modules/proxy/service.py +2600 -0
  645. app/modules/proxy/source_admission.py +125 -0
  646. app/modules/proxy/source_dispatch.py +999 -0
  647. app/modules/proxy/sticky_repository.py +831 -0
  648. app/modules/proxy/thread_anchors.py +491 -0
  649. app/modules/proxy/tool_call_dedupe.py +802 -0
  650. app/modules/proxy/types.py +54 -0
  651. app/modules/proxy/work_admission.py +121 -0
  652. app/modules/quota_planner/__init__.py +1 -0
  653. app/modules/quota_planner/api.py +276 -0
  654. app/modules/quota_planner/logic.py +743 -0
  655. app/modules/quota_planner/repository.py +722 -0
  656. app/modules/quota_planner/scheduler.py +212 -0
  657. app/modules/quota_planner/schemas.py +102 -0
  658. app/modules/quota_planner/warmup.py +693 -0
  659. app/modules/rate_limit_reset_credits/__init__.py +0 -0
  660. app/modules/rate_limit_reset_credits/api.py +601 -0
  661. app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
  662. app/modules/rate_limit_reset_credits/store.py +114 -0
  663. app/modules/reports/__init__.py +1 -0
  664. app/modules/reports/api.py +134 -0
  665. app/modules/reports/cache.py +74 -0
  666. app/modules/reports/filters.py +27 -0
  667. app/modules/reports/repository.py +525 -0
  668. app/modules/reports/rollup.py +150 -0
  669. app/modules/reports/rollup_read.py +148 -0
  670. app/modules/reports/schemas.py +123 -0
  671. app/modules/reports/service.py +287 -0
  672. app/modules/reports/thread_identity.py +256 -0
  673. app/modules/request_logs/__init__.py +0 -0
  674. app/modules/request_logs/api.py +224 -0
  675. app/modules/request_logs/cost_backfill.py +154 -0
  676. app/modules/request_logs/mappers.py +97 -0
  677. app/modules/request_logs/repository.py +1765 -0
  678. app/modules/request_logs/schemas.py +145 -0
  679. app/modules/request_logs/service.py +348 -0
  680. app/modules/role_mappings/__init__.py +0 -0
  681. app/modules/role_mappings/api.py +206 -0
  682. app/modules/role_mappings/matching.py +55 -0
  683. app/modules/role_mappings/repository.py +106 -0
  684. app/modules/role_mappings/schemas.py +67 -0
  685. app/modules/role_mappings/service.py +334 -0
  686. app/modules/runtime/__init__.py +1 -0
  687. app/modules/runtime/api.py +18 -0
  688. app/modules/runtime/schemas.py +14 -0
  689. app/modules/runtime/service.py +218 -0
  690. app/modules/scim/__init__.py +1 -0
  691. app/modules/scim/api.py +194 -0
  692. app/modules/scim/dependencies.py +112 -0
  693. app/modules/scim/errors.py +77 -0
  694. app/modules/scim/management_api.py +156 -0
  695. app/modules/scim/repository.py +212 -0
  696. app/modules/scim/schemas.py +145 -0
  697. app/modules/scim/service.py +786 -0
  698. app/modules/scim/tokens.py +43 -0
  699. app/modules/settings/__init__.py +1 -0
  700. app/modules/settings/api.py +1677 -0
  701. app/modules/settings/repository.py +606 -0
  702. app/modules/settings/schemas.py +496 -0
  703. app/modules/settings/service.py +769 -0
  704. app/modules/shared/__init__.py +0 -0
  705. app/modules/shared/schemas.py +22 -0
  706. app/modules/sticky_sessions/__init__.py +1 -0
  707. app/modules/sticky_sessions/api.py +118 -0
  708. app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
  709. app/modules/sticky_sessions/schemas.py +78 -0
  710. app/modules/sticky_sessions/service.py +196 -0
  711. app/modules/telemetry/__init__.py +5 -0
  712. app/modules/telemetry/api.py +115 -0
  713. app/modules/telemetry/clients.py +62 -0
  714. app/modules/telemetry/consent.py +115 -0
  715. app/modules/telemetry/scheduler.py +85 -0
  716. app/modules/telemetry/schemas.py +163 -0
  717. app/modules/telemetry/sender.py +221 -0
  718. app/modules/telemetry/snapshot.py +490 -0
  719. app/modules/usage/__init__.py +0 -0
  720. app/modules/usage/additional_quota_keys.py +379 -0
  721. app/modules/usage/api.py +45 -0
  722. app/modules/usage/background_repository.py +147 -0
  723. app/modules/usage/builders.py +559 -0
  724. app/modules/usage/depletion_service.py +277 -0
  725. app/modules/usage/live_ingest.py +356 -0
  726. app/modules/usage/mappers.py +29 -0
  727. app/modules/usage/plan_downgrade_observations.py +488 -0
  728. app/modules/usage/repository.py +1890 -0
  729. app/modules/usage/schemas.py +68 -0
  730. app/modules/usage/service.py +102 -0
  731. app/modules/usage/updater.py +1488 -0
  732. app/static/assets/Trans-Cj-J9mVA.js +1 -0
  733. app/static/assets/access-page-DHzZiXZi.js +1 -0
  734. app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
  735. app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
  736. app/static/assets/account-status-DIsU5sUS.js +1 -0
  737. app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
  738. app/static/assets/accounts-page-Bbai8ift.js +3 -0
  739. app/static/assets/api-Dwa7XxPe.js +1 -0
  740. app/static/assets/api-client-dugX1Pti.js +55 -0
  741. app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
  742. app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
  743. app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
  744. app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
  745. app/static/assets/apis-page-BvXz9c6i.js +2 -0
  746. app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
  747. app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
  748. app/static/assets/automations-page-DQgNAJOR.js +3 -0
  749. app/static/assets/badge-DqMb9es0.js +1 -0
  750. app/static/assets/blur-email-HEvKagG2.js +1 -0
  751. app/static/assets/button-BjVmmiGW.js +1 -0
  752. app/static/assets/checkbox-apsl9vzk.js +1 -0
  753. app/static/assets/chevron-left-1d_pHVa5.js +1 -0
  754. app/static/assets/circle-alert-BS0xd_o1.js +1 -0
  755. app/static/assets/clsx-DB0hHKMi.js +1 -0
  756. app/static/assets/colors-BBy8GEtr.js +1 -0
  757. app/static/assets/copy-button-D7nAvOKR.js +1 -0
  758. app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
  759. app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
  760. app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
  761. app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
  762. app/static/assets/donut-chart-Bxc69cra.js +1 -0
  763. app/static/assets/download-kQteIcsB.js +1 -0
  764. app/static/assets/ellipsis-B_5OgIre.js +1 -0
  765. app/static/assets/empty-state-JcA7tc8m.js +1 -0
  766. app/static/assets/es2015-bMH8YKZT.js +41 -0
  767. app/static/assets/es6-B264ni6W.js +69 -0
  768. app/static/assets/external-link-UVAOgMQ0.js +1 -0
  769. app/static/assets/i18n-BWqZ9Ua2.js +1 -0
  770. app/static/assets/inbox-B_RM2s1J.js +1 -0
  771. app/static/assets/index-DIpd264Z.js +23 -0
  772. app/static/assets/index-DMeLKRJx.css +2 -0
  773. app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
  774. app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
  775. app/static/assets/iso-Bpe5Ihbb.js +1 -0
  776. app/static/assets/label-CGaLmLNf.js +1 -0
  777. app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
  778. app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
  779. app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
  780. app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
  781. app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
  782. app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
  783. app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
  784. app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
  785. app/static/assets/payload-CIYnGZz9.js +1 -0
  786. app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
  787. app/static/assets/report-chart-card-BXDliwcc.js +1 -0
  788. app/static/assets/reports-page-CGCJeMHG.js +3 -0
  789. app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
  790. app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  791. app/static/assets/search-HfhqwERt.js +1 -0
  792. app/static/assets/select-Cl33bz-g.js +1 -0
  793. app/static/assets/settings-page-mZj01HYl.js +2 -0
  794. app/static/assets/shim-CZwyM4Q7.js +1 -0
  795. app/static/assets/skeleton-C_EUc4Rp.js +1 -0
  796. app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
  797. app/static/assets/status-badge-CHHA5X6c.js +1 -0
  798. app/static/assets/switch-CzxgokcM.js +1 -0
  799. app/static/assets/table-DXaNSQ0G.js +1 -0
  800. app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
  801. app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
  802. app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
  803. app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
  804. app/static/assets/trash-D3KyWr1h.js +1 -0
  805. app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
  806. app/static/assets/use-accounts-PWlVGw3z.js +1 -0
  807. app/static/assets/use-auth-CuDlW3iC.js +1 -0
  808. app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
  809. app/static/assets/use-model-sources-Bul24JBu.js +1 -0
  810. app/static/assets/use-models-DUCPUuZg.js +1 -0
  811. app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
  812. app/static/assets/useTranslation--56nHhlH.js +1 -0
  813. app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
  814. app/static/assets/users-uIC3TIlE.js +1 -0
  815. app/static/assets/vendor-query-_bHZuxEx.js +1 -0
  816. app/static/assets/vendor-react-BVL0YZPW.js +9 -0
  817. app/static/favicon.svg +10 -0
  818. app/static/fonts/GeistSans-Variable.woff2 +0 -0
  819. app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  820. app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  821. app/static/index.html +29 -0
  822. codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
  823. codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
  824. codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
  825. codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
  826. codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
  827. config/additional_quota_registry.json +11 -0
@@ -0,0 +1,1679 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from sqlalchemy import case, delete, func, or_, select, text, update
11
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
12
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
13
+ from sqlalchemy.exc import OperationalError
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from app.core.auth import extract_id_token_claims, resolve_seat_identity
17
+ from app.core.crypto import TokenEncryptor
18
+ from app.core.upstream_proxy.cache import get_upstream_route_cache
19
+ from app.core.utils.time import utcnow
20
+ from app.db.account_identity_lock import advisory_lock_key, lock_postgresql_account_identities
21
+ from app.db.models import (
22
+ Account,
23
+ AccountLimitWarmup,
24
+ AccountStatus,
25
+ AccountUsageRollup,
26
+ AdditionalUsageHistory,
27
+ ApiKeyAccountAssignment,
28
+ DashboardSettings,
29
+ HttpBridgeSessionAlias,
30
+ HttpBridgeSessionRecord,
31
+ HttpBridgeSessionState,
32
+ RequestLog,
33
+ RuntimeSentinel,
34
+ StickySession,
35
+ StickySessionKind,
36
+ UsageHistory,
37
+ )
38
+ from app.db.session import sqlite_writer_section
39
+ from app.db.sqlite_lock_retry import retry_on_sqlite_lock
40
+ from app.modules.accounts.usage_rollup import (
41
+ AccountUsageRollupRepository,
42
+ deduped_usage_aggregate_stmt,
43
+ lock_fold_state,
44
+ merge_rollups_into,
45
+ )
46
+ from app.modules.accounts.usage_time_rollup import (
47
+ merge_time_rollups_into,
48
+ mirror_account_hard_delete_into_time_rollups,
49
+ mirror_account_soft_delete_into_time_rollups,
50
+ )
51
+ from app.modules.usage.additional_quota_keys import normalize_additional_quota_routing_policy_overrides
52
+ from app.modules.usage.plan_downgrade_observations import discard_plan_downgrade_observations
53
+ from app.modules.usage.repository import _clear_bulk_history_since_sqlite_cache
54
+
55
+ _SETTINGS_ROW_ID = 1
56
+ _DUPLICATE_ACCOUNT_SUFFIX = "__copy"
57
+ # deactivation_reason stamped by the fast DELETE path while the background
58
+ # worker drains the account's rows. The authoritative pending marker is
59
+ # accounts.delete_requested_at; the reason string is operator-facing only.
60
+ ACCOUNT_PENDING_DELETION_REASON = "pending_deletion"
61
+
62
+
63
+ def credentials_replaced_since_wipe(
64
+ access_token_encrypted: bytes,
65
+ refresh_token_encrypted: bytes,
66
+ id_token_encrypted: bytes,
67
+ ) -> bool:
68
+ """True when a marked account's token ciphertext is no longer the
69
+ empty-credential wipe stamped by :meth:`AccountsRepository.begin_delete`.
70
+
71
+ New-code credential replacements clear the pending-deletion marker in the
72
+ same transaction, but a replacement handled by a PRE-UPGRADE replica
73
+ during a rolling deploy writes fresh ciphertext without knowing the
74
+ marker columns. Fresh (non-wiped) credentials on a still-marked row are
75
+ therefore themselves the supersede signal; the caller must clear the
76
+ marker and abandon the deletion. ALL THREE token fields are inspected: a
77
+ legal replacement may carry an empty refresh token while providing fresh
78
+ access/id material, and mistaking it for the wipe would finalize a
79
+ freshly replaced account. Undecryptable material also counts as
80
+ replaced — never finalize a row whose credentials we cannot attribute to
81
+ our own wipe.
82
+ """
83
+ encryptor = TokenEncryptor()
84
+ for ciphertext in (access_token_encrypted, refresh_token_encrypted, id_token_encrypted):
85
+ try:
86
+ if encryptor.decrypt(ciphertext) != "":
87
+ return True
88
+ except Exception:
89
+ return True
90
+ return False
91
+
92
+
93
+ _UNSET = object()
94
+ _HARD_STICKY_UNAVAILABLE_STATUSES = frozenset(
95
+ (AccountStatus.PAUSED, AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED)
96
+ )
97
+ _HARD_STICKY_OUTAGE_GRACE_SEEDED_SENTINEL = "hard_sticky_outage_grace_seeded"
98
+
99
+
100
+ def _is_missing_hard_sticky_seed_table(exc: OperationalError) -> bool:
101
+ """Return True when startup grace seeding hit a not-yet-migrated database.
102
+
103
+ ``seed_hard_sticky_outage_grace_on_startup`` runs unguarded in the app
104
+ lifespan. With ``database_migrate_on_startup=false`` (operator-managed
105
+ migrations) the process can boot against a legacy database that does not
106
+ have ``runtime_sentinels`` (or, on a brand-new file, ``accounts``) yet;
107
+ before this guard that boot crashed with an unhandled sqlite
108
+ ``OperationalError``. The seeding is a best-effort one-time backfill: on a
109
+ missing table it rolls back and returns 0 without stamping the sentinel,
110
+ so the first boot after migrations run still performs the backfill. Only
111
+ the sqlite "no such table" shape is matched — this deployment mode boots
112
+ sqlite before migration; every other OperationalError still propagates.
113
+ """
114
+ message = str(exc).lower()
115
+ return "no such table: runtime_sentinels" in message or "no such table: accounts" in message
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class AccountRequestUsageSummary:
120
+ request_count: int
121
+ total_tokens: int
122
+ cached_input_tokens: int
123
+ total_cost_usd: float
124
+
125
+
126
+ # The account-listing request-usage summary dedupes and re-aggregates the
127
+ # un-folded raw tail on every dashboard accounts load, and the displayed
128
+ # lifetime totals tolerate short staleness. Cache the merged summaries per
129
+ # account-id signature for a small fixed TTL, mirroring the request-log
130
+ # COUNT cache (issue #1340 / PRINCIPLES.md P2); the test suite patches the
131
+ # TTL to 0 so summaries stay exact within a test. Account deletion and
132
+ # duplicate-identity consolidation clear the cache because they re-attribute
133
+ # usage rather than merely append to it.
134
+ _SUMMARY_CACHE_TTL_SECONDS = 30.0
135
+ _SUMMARY_CACHE_MAX_ENTRIES = 64
136
+ _request_usage_summary_cache: dict[tuple[str, ...] | None, tuple[dict[str, AccountRequestUsageSummary], float]] = {}
137
+ # Invalidation generation: a fill that was already computing when a clear
138
+ # happened must not re-populate the cache with its pre-clear result. Fills
139
+ # capture the generation before their first await and stores are discarded
140
+ # on mismatch. Deletion/consolidation clear synchronously right after their
141
+ # commit (no await in between), so every store either precedes the commit
142
+ # (its stale data is wiped by the clear) or observes the bumped generation.
143
+ _summary_cache_generation = 0
144
+
145
+
146
+ def _clear_request_usage_summary_cache() -> None:
147
+ global _summary_cache_generation
148
+ _summary_cache_generation += 1
149
+ _request_usage_summary_cache.clear()
150
+
151
+
152
+ def _cached_request_usage_summaries(
153
+ key: tuple[str, ...] | None,
154
+ ) -> dict[str, AccountRequestUsageSummary] | None:
155
+ entry = _request_usage_summary_cache.get(key)
156
+ if entry is None:
157
+ return None
158
+ summaries, expires_at = entry
159
+ if time.monotonic() >= expires_at:
160
+ _request_usage_summary_cache.pop(key, None)
161
+ return None
162
+ return summaries
163
+
164
+
165
+ def _store_request_usage_summaries(
166
+ key: tuple[str, ...] | None,
167
+ summaries: dict[str, AccountRequestUsageSummary],
168
+ ttl_seconds: float,
169
+ generation: int,
170
+ ) -> None:
171
+ if generation != _summary_cache_generation:
172
+ return
173
+ if len(_request_usage_summary_cache) >= _SUMMARY_CACHE_MAX_ENTRIES:
174
+ oldest = min(
175
+ _request_usage_summary_cache,
176
+ key=lambda existing: _request_usage_summary_cache[existing][1],
177
+ )
178
+ _request_usage_summary_cache.pop(oldest, None)
179
+ _request_usage_summary_cache[key] = (summaries, time.monotonic() + ttl_seconds)
180
+
181
+
182
+ class AccountIdentityConflictError(Exception):
183
+ def __init__(self, email: str) -> None:
184
+ self.email = email
185
+ super().__init__(
186
+ f"Cannot overwrite account for email '{email}' because multiple matching accounts exist. "
187
+ "Remove duplicates or enable import without overwrite."
188
+ )
189
+
190
+
191
+ class AccountIdentityRelockError(RuntimeError):
192
+ """Raised after identity membership changes across both bounded lock attempts."""
193
+
194
+
195
+ class AccountsRepository:
196
+ def __init__(self, session: AsyncSession) -> None:
197
+ self._session = session
198
+
199
+ @property
200
+ def session(self) -> AsyncSession:
201
+ return self._session
202
+
203
+ async def get_by_id(self, account_id: str) -> Account | None:
204
+ return await self._session.get(Account, account_id)
205
+
206
+ async def get_by_id_fresh(self, account_id: str) -> Account | None:
207
+ """Re-read one account with a real SELECT, refreshing the identity map.
208
+
209
+ Unlike ``get_by_id`` (``session.get``), this never returns a cached
210
+ identity-map object without hitting the database, so callers checking
211
+ for concurrently rotated token material observe the latest committed
212
+ row.
213
+ """
214
+ result = await self._session.execute(
215
+ select(Account).where(Account.id == account_id).execution_options(populate_existing=True)
216
+ )
217
+ return result.scalar_one_or_none()
218
+
219
+ async def list_accounts(self, *, refresh_existing: bool = False) -> list[Account]:
220
+ # Accounts marked for background deletion are already deleted from the
221
+ # operator's point of view: they never appear in listings (dashboard,
222
+ # usage refresh, automations) even though their rows survive until the
223
+ # deletion worker finishes draining them.
224
+ stmt = select(Account).where(Account.delete_requested_at.is_(None)).order_by(Account.email)
225
+ if refresh_existing:
226
+ stmt = stmt.execution_options(populate_existing=True)
227
+ result = await self._session.execute(stmt)
228
+ return list(result.scalars().all())
229
+
230
+ async def list_accounts_by_ids(self, account_ids: list[str], *, refresh_existing: bool = False) -> list[Account]:
231
+ if not account_ids:
232
+ return []
233
+ stmt = (
234
+ select(Account)
235
+ .where(Account.id.in_(account_ids))
236
+ .where(Account.delete_requested_at.is_(None))
237
+ .order_by(Account.email)
238
+ )
239
+ if refresh_existing:
240
+ stmt = stmt.execution_options(populate_existing=True)
241
+ result = await self._session.execute(stmt)
242
+ return list(result.scalars().all())
243
+
244
+ async def list_request_usage_summary_by_account(
245
+ self,
246
+ account_ids: list[str] | None = None,
247
+ ) -> dict[str, AccountRequestUsageSummary]:
248
+ ttl_seconds = _SUMMARY_CACHE_TTL_SECONDS
249
+ cache_key = tuple(sorted(account_ids)) if account_ids is not None else None
250
+ generation = _summary_cache_generation
251
+ if ttl_seconds > 0:
252
+ cached = _cached_request_usage_summaries(cache_key)
253
+ if cached is not None:
254
+ return dict(cached)
255
+ rollup_repo = AccountUsageRollupRepository(self._session)
256
+ folded, watermark = await rollup_repo.read_state(account_ids)
257
+
258
+ merged: dict[str, list[float]] = {
259
+ account_id: [
260
+ sums.request_count,
261
+ sums.input_tokens,
262
+ sums.output_tokens,
263
+ sums.cached_input_tokens,
264
+ sums.total_cost_usd,
265
+ ]
266
+ for account_id, sums in folded.items()
267
+ }
268
+ tail_stmt = deduped_usage_aggregate_stmt(account_ids=account_ids, after_exclusive=watermark)
269
+ result = await self._session.execute(tail_stmt)
270
+ for (
271
+ account_id,
272
+ request_count,
273
+ input_tokens,
274
+ output_tokens,
275
+ cached_input_tokens,
276
+ total_cost_usd,
277
+ ) in result.all():
278
+ if not account_id:
279
+ continue
280
+ totals = merged.setdefault(account_id, [0, 0, 0, 0, 0.0])
281
+ totals[0] += int(request_count or 0)
282
+ totals[1] += int(input_tokens or 0)
283
+ totals[2] += int(output_tokens or 0)
284
+ totals[3] += int(cached_input_tokens or 0)
285
+ totals[4] += float(total_cost_usd or 0.0)
286
+
287
+ summaries: dict[str, AccountRequestUsageSummary] = {}
288
+ for account_id, (request_count, input_sum, output_sum, cached_sum, total_cost_usd) in merged.items():
289
+ input_total = int(input_sum)
290
+ output_total = int(output_sum)
291
+ cached_total = max(0, min(int(cached_sum), input_total))
292
+ summaries[account_id] = AccountRequestUsageSummary(
293
+ request_count=int(request_count),
294
+ total_tokens=input_total + output_total,
295
+ cached_input_tokens=cached_total,
296
+ total_cost_usd=round(float(total_cost_usd), 6),
297
+ )
298
+ if ttl_seconds > 0:
299
+ _store_request_usage_summaries(cache_key, summaries, ttl_seconds, generation)
300
+ return dict(summaries)
301
+ return summaries
302
+
303
+ async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None:
304
+ result = await self._session.execute(
305
+ select(Account)
306
+ .where(Account.chatgpt_account_id == chatgpt_account_id)
307
+ .where(Account.status.notin_((AccountStatus.DEACTIVATED, AccountStatus.PAUSED)))
308
+ .limit(1)
309
+ )
310
+ return result.scalar_one_or_none()
311
+
312
+ async def upsert(
313
+ self,
314
+ account: Account,
315
+ *,
316
+ merge_by_email: bool | None = None,
317
+ merge_by_chatgpt_identity: bool = False,
318
+ ) -> Account:
319
+ async with sqlite_writer_section():
320
+ return await self._upsert_unlocked(
321
+ account,
322
+ merge_by_email=merge_by_email,
323
+ merge_by_chatgpt_identity=merge_by_chatgpt_identity,
324
+ )
325
+
326
+ async def _upsert_unlocked(
327
+ self,
328
+ account: Account,
329
+ *,
330
+ merge_by_email: bool | None = None,
331
+ merge_by_chatgpt_identity: bool = False,
332
+ _identity_lock_attempt: int = 0,
333
+ ) -> Account:
334
+ dialect_name = self._dialect_name()
335
+ sqlite_lock_acquired = False
336
+ if merge_by_email is None:
337
+ if dialect_name == "sqlite":
338
+ await self._acquire_sqlite_merge_lock()
339
+ sqlite_lock_acquired = True
340
+ merge_by_email = await self._merge_by_email_enabled()
341
+
342
+ if dialect_name == "sqlite" and not sqlite_lock_acquired:
343
+ # sqlite BEGIN IMMEDIATE serializes all writers globally, so
344
+ # the identity-reconciliation branch is already mutually
345
+ # exclusive on this dialect.
346
+ await self._acquire_sqlite_merge_lock()
347
+ elif dialect_name == "postgresql":
348
+ # Upstream identity membership always serializes before email
349
+ # locks and account row locks. This applies to ordinary imports as
350
+ # well as explicit identity reconciliation because either can add,
351
+ # replace, or remove a membership used by live-usage fallback.
352
+ locked_identities = await self._lock_postgresql_upsert_identity_candidates(
353
+ account,
354
+ include_email=bool(merge_by_email),
355
+ )
356
+ if merge_by_email:
357
+ await self._acquire_postgresql_merge_lock(account.email)
358
+ elif not locked_identities:
359
+ await self._acquire_postgresql_identity_lock(account.id)
360
+ if not await self._postgresql_upsert_identity_candidates_are_locked(
361
+ account,
362
+ include_email=bool(merge_by_email),
363
+ locked_identities=locked_identities,
364
+ ):
365
+ await self._session.rollback()
366
+ if _identity_lock_attempt >= 1:
367
+ raise AccountIdentityRelockError(
368
+ "Account identity candidates changed during PostgreSQL upsert locking"
369
+ )
370
+ return await self._upsert_unlocked(
371
+ account,
372
+ merge_by_email=merge_by_email,
373
+ merge_by_chatgpt_identity=merge_by_chatgpt_identity,
374
+ _identity_lock_attempt=_identity_lock_attempt + 1,
375
+ )
376
+
377
+ # Identity-aware reconciliation runs before the deterministic-id
378
+ # check so that a deactivated row whose refresh token was revoked
379
+ # is reused on reauth instead of being shadowed by an __copy row
380
+ # under the same upstream ChatGPT identity (issue #788).
381
+ #
382
+ # This path is intentionally independent of merge_by_email: the
383
+ # OAuth reauth caller passes merge_by_chatgpt_identity=True even
384
+ # when the operator has opted into "import without overwrite",
385
+ # because that setting governs the dashboard import path (side-
386
+ # by-side rows for the same email) rather than the reauth path
387
+ # (one local row per upstream identity).
388
+ if merge_by_chatgpt_identity and account.chatgpt_account_id:
389
+ canonical = await self._account_by_chatgpt_identity(
390
+ account.chatgpt_account_id,
391
+ workspace_id=account.workspace_id,
392
+ email=account.email,
393
+ )
394
+ if canonical is not None:
395
+ await self._apply_account_replacement(canonical, account)
396
+ usage_cache_dirty = await self._reconcile_chatgpt_identity_duplicates(
397
+ canonical=canonical,
398
+ chatgpt_account_id=account.chatgpt_account_id,
399
+ workspace_id=account.workspace_id,
400
+ email=account.email,
401
+ )
402
+ await self._session.commit()
403
+ if usage_cache_dirty:
404
+ _clear_bulk_history_since_sqlite_cache()
405
+ # Consolidation re-attributes request logs and rollup sums
406
+ # to the canonical account; cached listing summaries would
407
+ # keep reporting the deleted duplicates until TTL expiry.
408
+ _clear_request_usage_summary_cache()
409
+ # Duplicate reconciliation deletes Account rows, cascading
410
+ # any account_proxy_bindings they owned. The route cache is
411
+ # keyed by deterministic account id, so stale duplicate-id
412
+ # outcomes must not survive the commit.
413
+ await get_upstream_route_cache().invalidate()
414
+ await self._session.refresh(canonical)
415
+ return canonical
416
+
417
+ existing = await self._session.get(Account, account.id)
418
+ if existing:
419
+ if merge_by_email or _is_workspace_less_reauth_for_known_slot(
420
+ existing,
421
+ account,
422
+ merge_by_chatgpt_identity=merge_by_chatgpt_identity,
423
+ ):
424
+ await self._apply_account_replacement(existing, account)
425
+ await self._session.commit()
426
+ await self._session.refresh(existing)
427
+ return existing
428
+ account.id = await self._next_available_account_id(account.id)
429
+
430
+ if merge_by_email:
431
+ existing_by_email = await self._single_account_by_email(account.email)
432
+ if existing_by_email:
433
+ await self._apply_account_replacement(existing_by_email, account)
434
+ await self._session.commit()
435
+ await self._session.refresh(existing_by_email)
436
+ return existing_by_email
437
+
438
+ self._session.add(account)
439
+ await self._session.commit()
440
+ await self._session.refresh(account)
441
+ return account
442
+
443
+ async def upsert_reauthorized(self, account: Account) -> Account:
444
+ return await self.upsert_account_slot(account, preserve_unknown_workspace_duplicates=False)
445
+
446
+ async def replace_reauthorized(self, account_id: str, account: Account) -> Account | None:
447
+ """Replace credentials on the exact local row selected for reauthentication."""
448
+ async with sqlite_writer_section():
449
+ if self._dialect_name() == "postgresql":
450
+ existing = await self._lock_postgresql_account_identity_membership(
451
+ account_id,
452
+ account.chatgpt_account_id,
453
+ )
454
+ else:
455
+ existing = await self._session.get(Account, account_id)
456
+ if existing is None:
457
+ return None
458
+ await self._apply_account_replacement(existing, account)
459
+ await self._session.commit()
460
+ await self._session.refresh(existing)
461
+ return existing
462
+
463
+ async def _apply_account_replacement(self, target: Account, source: Account) -> None:
464
+ """Apply freshly imported or reauthorized material onto an existing row.
465
+
466
+ Every in-place credential replacement goes through here rather than
467
+ calling :func:`_apply_account_updates` directly, so replacing a
468
+ credential always discards the account's pending plan-downgrade
469
+ evidence in the same transaction: evidence gathered under the previous
470
+ credential must not count toward a downgrade for the new one (#1456).
471
+ Routine token rotation is a different event with its own path
472
+ (:meth:`rotate_tokens`) and deliberately does not discard evidence.
473
+ """
474
+ _apply_account_updates(target, source)
475
+ await discard_plan_downgrade_observations(self._session, target.id)
476
+
477
+ async def upsert_account_slot(
478
+ self,
479
+ account: Account,
480
+ *,
481
+ preserve_unknown_workspace_duplicates: bool | None = None,
482
+ preserve_identity_slots: bool = False,
483
+ ) -> Account:
484
+ async with sqlite_writer_section():
485
+ return await self._upsert_account_slot_unlocked(
486
+ account,
487
+ preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
488
+ preserve_identity_slots=preserve_identity_slots,
489
+ )
490
+
491
+ async def _upsert_account_slot_unlocked(
492
+ self,
493
+ account: Account,
494
+ *,
495
+ preserve_unknown_workspace_duplicates: bool | None = None,
496
+ preserve_identity_slots: bool = False,
497
+ _identity_lock_attempt: int = 0,
498
+ ) -> Account:
499
+ if preserve_unknown_workspace_duplicates is None:
500
+ preserve_unknown_workspace_duplicates = not await self._merge_by_email_enabled()
501
+ dialect_name = self._dialect_name()
502
+ if dialect_name == "sqlite":
503
+ await self._acquire_sqlite_merge_lock()
504
+ elif dialect_name == "postgresql":
505
+ locked_identities = await self._lock_postgresql_upsert_identity_candidates(
506
+ account,
507
+ include_email=True,
508
+ )
509
+ for lock_key in sorted(
510
+ _slot_lock_keys(
511
+ account,
512
+ preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
513
+ )
514
+ ):
515
+ await self._acquire_postgresql_identity_lock(lock_key)
516
+ if not await self._postgresql_upsert_identity_candidates_are_locked(
517
+ account,
518
+ include_email=True,
519
+ locked_identities=locked_identities,
520
+ ):
521
+ await self._session.rollback()
522
+ if _identity_lock_attempt >= 1:
523
+ raise AccountIdentityRelockError(
524
+ "Account identity candidates changed during PostgreSQL slot locking"
525
+ )
526
+ return await self._upsert_account_slot_unlocked(
527
+ account,
528
+ preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
529
+ preserve_identity_slots=preserve_identity_slots,
530
+ _identity_lock_attempt=_identity_lock_attempt + 1,
531
+ )
532
+
533
+ existing = await self._account_by_slot_identity(account)
534
+ if existing:
535
+ await self._apply_account_replacement(existing, account)
536
+ await self._session.commit()
537
+ await self._session.refresh(existing)
538
+ return existing
539
+
540
+ existing_by_id = await self._session.get(Account, account.id)
541
+ if existing_by_id:
542
+ if _same_unknown_workspace_identity(existing_by_id, account) and not preserve_unknown_workspace_duplicates:
543
+ await self._apply_account_replacement(existing_by_id, account)
544
+ await self._session.commit()
545
+ await self._session.refresh(existing_by_id)
546
+ return existing_by_id
547
+ account.id = await self._next_available_account_id(account.id)
548
+ elif not preserve_unknown_workspace_duplicates:
549
+ if _workspace_slot_key(account):
550
+ existing_by_email = await self._single_unknown_workspace_account_by_email(account.email)
551
+ elif preserve_identity_slots and account.chatgpt_account_id:
552
+ existing_by_email = None
553
+ else:
554
+ existing_by_email = await self._single_account_by_email(account.email)
555
+ if existing_by_email and not _can_reuse_email_fallback(existing_by_email, account):
556
+ existing_by_email = None
557
+ if existing_by_email:
558
+ await self._apply_account_replacement(existing_by_email, account)
559
+ await self._session.commit()
560
+ await self._session.refresh(existing_by_email)
561
+ return existing_by_email
562
+
563
+ self._session.add(account)
564
+ await self._session.commit()
565
+ await self._session.refresh(account)
566
+ return account
567
+
568
+ async def _account_by_chatgpt_identity(
569
+ self,
570
+ chatgpt_account_id: str,
571
+ *,
572
+ workspace_id: str | None,
573
+ email: str | None,
574
+ ) -> Account | None:
575
+ """Return the canonical local account row for a ChatGPT identity.
576
+
577
+ Order of preference, so that reauth targets the matching real-email
578
+ slot when one exists, while still allowing an upstream email change
579
+ to reuse a single unambiguous identity row:
580
+
581
+ 1. The oldest row with the incoming email.
582
+ 2. The only identity row when no email match exists.
583
+ """
584
+
585
+ stmt = select(Account).where(Account.chatgpt_account_id == chatgpt_account_id)
586
+ order_by: list[Any] = [Account.created_at.asc(), Account.id.asc()]
587
+ if workspace_id:
588
+ stmt = stmt.where(or_(Account.workspace_id == workspace_id, Account.workspace_id.is_(None)))
589
+ order_by.insert(0, Account.workspace_id.is_(None).asc())
590
+ else:
591
+ stmt = stmt.where(Account.workspace_id.is_(None))
592
+
593
+ result = await self._session.execute(stmt.order_by(*order_by))
594
+ candidates = list(result.scalars().all())
595
+ if not candidates:
596
+ return None
597
+ if not email:
598
+ return candidates[0]
599
+
600
+ for candidate in candidates:
601
+ if candidate.email == email:
602
+ return candidate
603
+
604
+ if len(candidates) == 1:
605
+ return candidates[0]
606
+ return None
607
+
608
+ async def _reconcile_chatgpt_identity_duplicates(
609
+ self,
610
+ canonical: Account,
611
+ chatgpt_account_id: str,
612
+ workspace_id: str | None,
613
+ email: str | None,
614
+ ) -> bool:
615
+ duplicate_stmt = select(Account.id).where(
616
+ Account.chatgpt_account_id == chatgpt_account_id,
617
+ Account.id != canonical.id,
618
+ )
619
+ if email:
620
+ duplicate_stmt = duplicate_stmt.where(Account.email == email)
621
+ if workspace_id is None:
622
+ duplicate_stmt = duplicate_stmt.where(Account.workspace_id.is_(None))
623
+ else:
624
+ duplicate_stmt = duplicate_stmt.where(Account.workspace_id == workspace_id)
625
+ duplicate_accounts = (await self._session.execute(duplicate_stmt)).scalars().all()
626
+ duplicate_ids = list(duplicate_accounts)
627
+ if not duplicate_ids:
628
+ return False
629
+
630
+ # Serialize against fold passes before reassigning any request logs:
631
+ # a fold overlapping this transaction could otherwise attribute the
632
+ # duplicates' logs to rollup rows this transaction deletes, leaving
633
+ # those logs behind the watermark but counted in no rollup.
634
+ await lock_fold_state(self._session)
635
+
636
+ duplicate_api_key_ids = (
637
+ (
638
+ await self._session.execute(
639
+ select(ApiKeyAccountAssignment.api_key_id).where(ApiKeyAccountAssignment.account_id == canonical.id)
640
+ )
641
+ )
642
+ .scalars()
643
+ .all()
644
+ )
645
+ existing_api_key_ids = set(duplicate_api_key_ids)
646
+
647
+ duplicate_assignments = (
648
+ (
649
+ await self._session.execute(
650
+ select(ApiKeyAccountAssignment).where(ApiKeyAccountAssignment.account_id.in_(duplicate_ids))
651
+ )
652
+ )
653
+ .scalars()
654
+ .all()
655
+ )
656
+ for assignment in duplicate_assignments:
657
+ if assignment.api_key_id in existing_api_key_ids:
658
+ await self._session.delete(assignment)
659
+ else:
660
+ assignment.account_id = canonical.id
661
+ existing_api_key_ids.add(assignment.api_key_id)
662
+
663
+ await self._session.execute(
664
+ update(UsageHistory).where(UsageHistory.account_id.in_(duplicate_ids)).values(account_id=canonical.id)
665
+ )
666
+ await self._session.execute(
667
+ update(AdditionalUsageHistory)
668
+ .where(AdditionalUsageHistory.account_id.in_(duplicate_ids))
669
+ .values(account_id=canonical.id)
670
+ )
671
+ await self._session.execute(
672
+ update(RequestLog).where(RequestLog.account_id.in_(duplicate_ids)).values(account_id=canonical.id)
673
+ )
674
+ await self._reconcile_limit_warmups(canonical.id, duplicate_ids)
675
+ await self._session.execute(
676
+ update(StickySession).where(StickySession.account_id.in_(duplicate_ids)).values(account_id=canonical.id)
677
+ )
678
+ await self._session.execute(
679
+ update(HttpBridgeSessionRecord)
680
+ .where(HttpBridgeSessionRecord.account_id.in_(duplicate_ids))
681
+ .values(account_id=canonical.id)
682
+ )
683
+ # Folded usage must follow the reassigned request logs, or the
684
+ # canonical account silently loses the duplicates' pre-watermark
685
+ # history from its lifetime totals.
686
+ await merge_rollups_into(self._session, canonical.id, duplicate_ids)
687
+ # Folded time-axis buckets follow the reassigned request logs too:
688
+ # bucket-wise merge-add onto the canonical account, duplicates
689
+ # removed, in this same fold-state-locked transaction.
690
+ await merge_time_rollups_into(self._session, canonical.id, duplicate_ids)
691
+ await self._session.execute(delete(Account).where(Account.id.in_(duplicate_ids)))
692
+ return True
693
+
694
+ async def _reconcile_limit_warmups(self, canonical_account_id: str, duplicate_ids: list[str]) -> None:
695
+ existing_keys = {
696
+ (window, reset_at)
697
+ for window, reset_at in (
698
+ await self._session.execute(
699
+ select(AccountLimitWarmup.window, AccountLimitWarmup.reset_at).where(
700
+ AccountLimitWarmup.account_id == canonical_account_id
701
+ )
702
+ )
703
+ ).all()
704
+ }
705
+ duplicate_warmups = (
706
+ (
707
+ await self._session.execute(
708
+ select(AccountLimitWarmup).where(AccountLimitWarmup.account_id.in_(duplicate_ids))
709
+ )
710
+ )
711
+ .scalars()
712
+ .all()
713
+ )
714
+ for warmup in duplicate_warmups:
715
+ key = (warmup.window, warmup.reset_at)
716
+ if key in existing_keys:
717
+ await self._session.delete(warmup)
718
+ else:
719
+ warmup.account_id = canonical_account_id
720
+ existing_keys.add(key)
721
+
722
+ async def update_status(
723
+ self,
724
+ account_id: str,
725
+ status: AccountStatus,
726
+ deactivation_reason: str | None = None,
727
+ reset_at: int | None = None,
728
+ blocked_at: int | None | object = _UNSET,
729
+ ) -> bool:
730
+ async with sqlite_writer_section():
731
+ previous_status = await self._session.scalar(
732
+ select(Account.status).where(Account.id == account_id).with_for_update()
733
+ )
734
+ values: dict[str, object | None] = {
735
+ "status": status,
736
+ "deactivation_reason": deactivation_reason,
737
+ "reset_at": reset_at,
738
+ }
739
+ if blocked_at is not _UNSET:
740
+ values["blocked_at"] = blocked_at
741
+ result = await self._session.execute(
742
+ update(Account)
743
+ .where(Account.id == account_id)
744
+ # An account marked for background deletion is terminal: a
745
+ # stale in-flight settlement (e.g. a 429 from a request that
746
+ # was selected before the DELETE) must not replace the
747
+ # DEACTIVATED/pending_deletion state and make the account
748
+ # selectable again mid-drain. Only a credential replacement
749
+ # (which clears the marker) may resurrect the row.
750
+ .where(Account.delete_requested_at.is_(None))
751
+ .values(**values)
752
+ .returning(Account.id)
753
+ )
754
+ updated_id = result.scalar_one_or_none()
755
+ if updated_id is not None and self._hard_sticky_outage_started(previous_status, status):
756
+ await self._refresh_hard_sticky_outage_grace(account_id)
757
+ if updated_id is not None and status == AccountStatus.DEACTIVATED:
758
+ await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
759
+ await self._close_http_bridge_sessions_for_account(account_id)
760
+ await self._session.commit()
761
+ return updated_id is not None
762
+
763
+ async def update_security_work_authorized(self, account_id: str, enabled: bool) -> bool:
764
+ async with sqlite_writer_section():
765
+ result = await self._session.execute(
766
+ update(Account)
767
+ .where(Account.id == account_id)
768
+ # Marked-for-deletion rows are gone from the operator's
769
+ # perspective: ID-based mutations must report not-found, as the
770
+ # synchronous delete did once the row was removed.
771
+ .where(Account.delete_requested_at.is_(None))
772
+ .values(security_work_authorized=enabled)
773
+ .returning(Account.id)
774
+ )
775
+ await self._session.commit()
776
+ return result.scalar_one_or_none() is not None
777
+
778
+ async def update_status_if_current(
779
+ self,
780
+ account_id: str,
781
+ status: AccountStatus,
782
+ deactivation_reason: str | None = None,
783
+ reset_at: int | None = None,
784
+ blocked_at: int | None | object = _UNSET,
785
+ *,
786
+ expected_status: AccountStatus,
787
+ expected_deactivation_reason: str | None = None,
788
+ expected_reset_at: int | None = None,
789
+ expected_blocked_at: int | None | object = _UNSET,
790
+ expected_refresh_token_encrypted: bytes | None = None,
791
+ ) -> bool:
792
+ async with sqlite_writer_section():
793
+ values: dict[str, object | None] = {
794
+ "status": status,
795
+ "deactivation_reason": deactivation_reason,
796
+ "reset_at": reset_at,
797
+ }
798
+ if blocked_at is not _UNSET:
799
+ values["blocked_at"] = blocked_at
800
+ stmt = (
801
+ update(Account)
802
+ .where(Account.id == account_id)
803
+ .where(Account.status == expected_status)
804
+ # Same pending-deletion fence as ``update_status``: marked
805
+ # rows are terminal for ordinary status writers.
806
+ .where(Account.delete_requested_at.is_(None))
807
+ .values(**values)
808
+ .returning(Account.id)
809
+ )
810
+ if expected_deactivation_reason is None:
811
+ stmt = stmt.where(Account.deactivation_reason.is_(None))
812
+ else:
813
+ stmt = stmt.where(Account.deactivation_reason == expected_deactivation_reason)
814
+ if expected_reset_at is None:
815
+ stmt = stmt.where(Account.reset_at.is_(None))
816
+ else:
817
+ stmt = stmt.where(Account.reset_at == expected_reset_at)
818
+ if expected_blocked_at is not _UNSET:
819
+ if expected_blocked_at is None:
820
+ stmt = stmt.where(Account.blocked_at.is_(None))
821
+ else:
822
+ stmt = stmt.where(Account.blocked_at == expected_blocked_at)
823
+ if expected_refresh_token_encrypted is not None:
824
+ # Guards permanent refresh-failure downgrades: a concurrent
825
+ # re-auth/import rotates the token ciphertext without touching
826
+ # status/reason/reset, and this write must lose that race.
827
+ stmt = stmt.where(Account.refresh_token_encrypted == expected_refresh_token_encrypted)
828
+ result = await self._session.execute(stmt)
829
+ updated_id = result.scalar_one_or_none()
830
+ if updated_id is not None and self._hard_sticky_outage_started(expected_status, status):
831
+ await self._refresh_hard_sticky_outage_grace(account_id)
832
+ if updated_id is not None and status == AccountStatus.DEACTIVATED:
833
+ await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
834
+ await self._close_http_bridge_sessions_for_account(account_id)
835
+ await self._session.commit()
836
+ return updated_id is not None
837
+
838
+ @staticmethod
839
+ def _hard_sticky_outage_started(
840
+ previous_status: AccountStatus | None,
841
+ status: AccountStatus,
842
+ ) -> bool:
843
+ return (
844
+ previous_status is not None
845
+ and previous_status not in _HARD_STICKY_UNAVAILABLE_STATUSES
846
+ and status in _HARD_STICKY_UNAVAILABLE_STATUSES
847
+ )
848
+
849
+ async def _refresh_hard_sticky_outage_grace(self, account_id: str) -> None:
850
+ """Start a fresh purge grace period when a hard owner goes unavailable."""
851
+
852
+ await self._session.execute(
853
+ update(StickySession)
854
+ .where(
855
+ StickySession.account_id == account_id,
856
+ StickySession.kind == StickySessionKind.CODEX_SESSION,
857
+ )
858
+ .values(updated_at=utcnow())
859
+ )
860
+
861
+ async def seed_hard_sticky_outage_grace_on_startup(self) -> int:
862
+ """Backfill, exactly once ever, a grace window for already-unavailable owners.
863
+
864
+ ``_refresh_hard_sticky_outage_grace`` only fires on a live status
865
+ transition into PAUSED/RATE_LIMITED/QUOTA_EXCEEDED, so it never runs
866
+ for an account that was already sitting in one of those statuses
867
+ before this process started (e.g. an outage that began minutes
868
+ before a deploy). Without this, the purge scheduler's very first
869
+ cleanup cycle after this feature ships could treat that mapping's
870
+ stale ``updated_at`` as proof of a long-dead owner and purge it, even
871
+ though the outage is brand new — violating the "merely transient
872
+ outage is never purged" invariant for the upgrade window.
873
+
874
+ That backfill only needs to happen once per database, not once per
875
+ process start. All replicas share one database, and reseeding on
876
+ every boot resets the grace clock for accounts that are still
877
+ (correctly) unavailable; if deploys or autoscaling cycle faster than
878
+ the purge cutoff, a durably-dead mapping's grace clock would never
879
+ run out and it would never be purged. ``runtime_sentinels`` gives
880
+ every replica a shared, durable "has this ever run" marker: the
881
+ first replica to atomically stamp
882
+ ``_HARD_STICKY_OUTAGE_GRACE_SEEDED_SENTINEL`` performs the backfill;
883
+ every later boot, on this or any other replica, finds the sentinel
884
+ already stamped and skips it, leaving the live per-transition hook
885
+ as the sole grace-clock source from then on.
886
+ """
887
+ dialect = self._session.get_bind().dialect.name
888
+ if dialect == "postgresql":
889
+ insert_fn = pg_insert
890
+ elif dialect == "sqlite":
891
+ insert_fn = sqlite_insert
892
+ else:
893
+ raise RuntimeError(f"Hard-sticky outage grace seeding sentinel unsupported for dialect={dialect!r}")
894
+ stamp_stmt = (
895
+ insert_fn(RuntimeSentinel)
896
+ .values(name=_HARD_STICKY_OUTAGE_GRACE_SEEDED_SENTINEL, value=utcnow().isoformat())
897
+ .on_conflict_do_nothing(index_elements=[RuntimeSentinel.name])
898
+ .returning(RuntimeSentinel.name)
899
+ )
900
+
901
+ async def seed_once() -> int:
902
+ async with sqlite_writer_section():
903
+ stamp_result = await self._session.execute(stamp_stmt)
904
+ stamped_by_this_boot = stamp_result.scalar_one_or_none() is not None
905
+ if not stamped_by_this_boot:
906
+ await self._session.commit()
907
+ return 0
908
+ account_ids = (
909
+ await self._session.scalars(
910
+ select(Account.id).where(Account.status.in_(_HARD_STICKY_UNAVAILABLE_STATUSES))
911
+ )
912
+ ).all()
913
+ for account_id in account_ids:
914
+ await self._refresh_hard_sticky_outage_grace(account_id)
915
+ await self._session.commit()
916
+ return len(account_ids)
917
+
918
+ try:
919
+ # This stamp is the first statement of its transaction on a
920
+ # startup-fresh connection, so it can simply lose SQLite's writer
921
+ # slot (issue #1949) — retry the whole attempt, rolling the failed
922
+ # transaction back first so the next one starts clean. Exhausting
923
+ # the budget still raises and fails startup, as it always has.
924
+ return await retry_on_sqlite_lock(
925
+ seed_once,
926
+ what="hard-sticky outage grace startup seed",
927
+ before_retry=self._session.rollback,
928
+ )
929
+ except OperationalError as exc:
930
+ if not _is_missing_hard_sticky_seed_table(exc):
931
+ raise
932
+ await self._session.rollback()
933
+ return 0
934
+
935
+ async def _close_http_bridge_sessions_for_account(self, account_id: str) -> None:
936
+ session_ids = select(HttpBridgeSessionRecord.id).where(HttpBridgeSessionRecord.account_id == account_id)
937
+ await self._session.execute(
938
+ delete(HttpBridgeSessionAlias).where(HttpBridgeSessionAlias.session_id.in_(session_ids))
939
+ )
940
+ await self._session.execute(
941
+ update(HttpBridgeSessionRecord)
942
+ .where(HttpBridgeSessionRecord.account_id == account_id)
943
+ .values(
944
+ account_id=None,
945
+ state=HttpBridgeSessionState.CLOSED,
946
+ closed_at=utcnow(),
947
+ owner_instance_id=None,
948
+ lease_expires_at=None,
949
+ latest_turn_state=None,
950
+ latest_response_id=None,
951
+ latest_input_item_count=None,
952
+ latest_input_full_fingerprint=None,
953
+ latest_pending_tool_calls_json=None,
954
+ )
955
+ )
956
+
957
+ async def update_alias(self, account_id: str, alias: str | None) -> bool:
958
+ async with sqlite_writer_section():
959
+ result = await self._session.execute(
960
+ update(Account)
961
+ .where(Account.id == account_id)
962
+ # Marked-for-deletion rows are gone from the operator's
963
+ # perspective: ID-based mutations must report not-found, as the
964
+ # synchronous delete did once the row was removed.
965
+ .where(Account.delete_requested_at.is_(None))
966
+ .values(alias=alias)
967
+ .returning(Account.id)
968
+ )
969
+ await self._session.commit()
970
+ return result.scalar_one_or_none() is not None
971
+
972
+ async def update_limit_warmup_enabled(self, account_id: str, enabled: bool) -> bool:
973
+ async with sqlite_writer_section():
974
+ result = await self._session.execute(
975
+ update(Account)
976
+ .where(Account.id == account_id)
977
+ # Marked-for-deletion rows are gone from the operator's
978
+ # perspective: ID-based mutations must report not-found, as the
979
+ # synchronous delete did once the row was removed.
980
+ .where(Account.delete_requested_at.is_(None))
981
+ .values(limit_warmup_enabled=enabled)
982
+ .returning(Account.id)
983
+ )
984
+ await self._session.commit()
985
+ return result.scalar_one_or_none() is not None
986
+
987
+ async def update_routing_policy(self, account_id: str, routing_policy: str) -> bool:
988
+ async with sqlite_writer_section():
989
+ result = await self._session.execute(
990
+ update(Account)
991
+ .where(Account.id == account_id)
992
+ # Marked-for-deletion rows are gone from the operator's
993
+ # perspective: ID-based mutations must report not-found, as the
994
+ # synchronous delete did once the row was removed.
995
+ .where(Account.delete_requested_at.is_(None))
996
+ .values(routing_policy=routing_policy)
997
+ .returning(Account.id)
998
+ )
999
+ await self._session.commit()
1000
+ return result.scalar_one_or_none() is not None
1001
+
1002
+ async def begin_delete(self, account_id: str, *, delete_history: bool = False) -> bool:
1003
+ """Mark an account for background deletion; commits in milliseconds.
1004
+
1005
+ Fast path of ``DELETE /api/accounts/{id}``: the account becomes
1006
+ terminal (``DEACTIVATED`` — every serving path already excludes it)
1007
+ and carries the pending-deletion marker that hides it from listings
1008
+ and enqueues it for the deletion worker, which drains its bulk rows
1009
+ in chunks and finalizes via :meth:`delete` with ``only_pending=True``.
1010
+
1011
+ The stored token ciphertext is overwritten with empty-credential
1012
+ ciphertext in the same transaction: the row outlives the DELETE
1013
+ response by the drain duration, and no reader — including a
1014
+ pre-upgrade replica during a rolling deploy, whose export endpoints
1015
+ do not know the marker — may still be able to produce usable
1016
+ credentials from it. A credential replacement (the only supersede
1017
+ path) writes fresh ciphertext, and token rotation is CAS-guarded on
1018
+ the pre-wipe refresh ciphertext, so a stale in-flight rotation
1019
+ misses rather than resurrecting the old material. Before the wipe,
1020
+ the non-secret seat identity is preserved: legacy rows whose
1021
+ ``chatgpt_user_id`` was never backfilled carry it only inside the
1022
+ id-token claims, and targeted reauthentication — the promised
1023
+ supersede path — verifies the seat against exactly those two
1024
+ sources, so ``chatgpt_user_id`` is backfilled from the claims when
1025
+ absent.
1026
+
1027
+ API-key account assignments are removed here as well (the FK cascade
1028
+ used to do this when the synchronous delete removed the row), so key
1029
+ listings and pooled-usage projections exclude the account
1030
+ immediately; the key's ``account_assignment_scope_enabled`` flag is
1031
+ persisted separately and keeps the key scoped.
1032
+
1033
+ Idempotent: a repeat request on an already-marked account succeeds
1034
+ without changing the frozen ``delete_history`` choice (first request
1035
+ wins — matching the synchronous behavior, where a second DELETE after
1036
+ the first completed found nothing left to escalate).
1037
+ """
1038
+ # Repeat requests short-circuit BEFORE the writer section / row lock:
1039
+ # a drain chunk holds the account row (and, on SQLite, the writer
1040
+ # section) for up to a few seconds, and the fast-path contract is a
1041
+ # millisecond-scale response. The unlocked read is safe because the
1042
+ # repeat changes nothing — the first request froze the variant and
1043
+ # the wipe/cleanup already ran — and a replacement racing this read
1044
+ # supersedes the deletion exactly as if it landed after this
1045
+ # response. When credentials were replaced WITHOUT clearing the
1046
+ # marker (a pre-upgrade replica's replacement), fall through to the
1047
+ # full path so an explicit re-delete re-wipes and re-arms.
1048
+ marked_row = (
1049
+ await self._session.execute(
1050
+ select(
1051
+ Account.delete_requested_at,
1052
+ Account.access_token_encrypted,
1053
+ Account.refresh_token_encrypted,
1054
+ Account.id_token_encrypted,
1055
+ ).where(Account.id == account_id)
1056
+ )
1057
+ ).first()
1058
+ if (
1059
+ marked_row is not None
1060
+ and marked_row[0] is not None
1061
+ and not credentials_replaced_since_wipe(marked_row[1], marked_row[2], marked_row[3])
1062
+ ):
1063
+ return True
1064
+ encryptor = TokenEncryptor()
1065
+ wiped_token = encryptor.encrypt("")
1066
+ async with sqlite_writer_section():
1067
+ seat_stmt = select(Account.chatgpt_user_id, Account.id_token_encrypted).where(Account.id == account_id)
1068
+ if self._dialect_name() == "postgresql":
1069
+ # Hold the row through the mark so the derived seat identity
1070
+ # cannot go stale between this read and the update below.
1071
+ seat_stmt = seat_stmt.with_for_update(key_share=True)
1072
+ seat_row = (await self._session.execute(seat_stmt)).first()
1073
+ if seat_row is None:
1074
+ await self._session.rollback()
1075
+ return False
1076
+ seat_user_id: str | None = seat_row[0]
1077
+ if seat_user_id is None:
1078
+ try:
1079
+ claims = extract_id_token_claims(encryptor.decrypt(seat_row[1]))
1080
+ seat_user_id = resolve_seat_identity(claims, claims.auth)
1081
+ except Exception:
1082
+ seat_user_id = None
1083
+ values: dict[str, Any] = {
1084
+ "status": AccountStatus.DEACTIVATED,
1085
+ "deactivation_reason": ACCOUNT_PENDING_DELETION_REASON,
1086
+ "reset_at": None,
1087
+ "blocked_at": None,
1088
+ "access_token_encrypted": wiped_token,
1089
+ "refresh_token_encrypted": wiped_token,
1090
+ "id_token_encrypted": wiped_token,
1091
+ "delete_requested_at": func.coalesce(Account.delete_requested_at, utcnow()),
1092
+ "delete_history_requested": case(
1093
+ (Account.delete_requested_at.is_(None), delete_history),
1094
+ else_=Account.delete_history_requested,
1095
+ ),
1096
+ }
1097
+ if seat_user_id is not None:
1098
+ values["chatgpt_user_id"] = seat_user_id
1099
+ result = await self._session.execute(
1100
+ update(Account).where(Account.id == account_id).values(**values).returning(Account.id)
1101
+ )
1102
+ updated_id = result.scalar_one_or_none()
1103
+ if updated_id is not None:
1104
+ # Same immediate cleanup the DEACTIVATED transition performs:
1105
+ # sticky mappings and bridge sessions must not outlive the
1106
+ # account's routability.
1107
+ await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
1108
+ await self._close_http_bridge_sessions_for_account(account_id)
1109
+ await self._session.execute(
1110
+ delete(ApiKeyAccountAssignment).where(ApiKeyAccountAssignment.account_id == account_id)
1111
+ )
1112
+ await self._session.commit()
1113
+ return updated_id is not None
1114
+
1115
+ async def delete(
1116
+ self,
1117
+ account_id: str,
1118
+ *,
1119
+ delete_history: bool = False,
1120
+ only_pending: bool = False,
1121
+ ) -> bool:
1122
+ async with sqlite_writer_section():
1123
+ if self._dialect_name() == "postgresql":
1124
+ # Identity membership precedes the fold-state lock so live
1125
+ # settlement and deletion cannot form an identity/fold cycle.
1126
+ locked_account = await self._lock_postgresql_account_identity_membership(account_id, None)
1127
+ pending_state = (
1128
+ None
1129
+ if locked_account is None
1130
+ else (
1131
+ locked_account.delete_requested_at,
1132
+ locked_account.delete_history_requested,
1133
+ locked_account.access_token_encrypted,
1134
+ locked_account.refresh_token_encrypted,
1135
+ locked_account.id_token_encrypted,
1136
+ )
1137
+ )
1138
+ else:
1139
+ pending_state = (
1140
+ await self._session.execute(
1141
+ select(
1142
+ Account.delete_requested_at,
1143
+ Account.delete_history_requested,
1144
+ Account.access_token_encrypted,
1145
+ Account.refresh_token_encrypted,
1146
+ Account.id_token_encrypted,
1147
+ ).where(Account.id == account_id)
1148
+ )
1149
+ ).first()
1150
+ if only_pending:
1151
+ # Background finalization: a credential replacement
1152
+ # (re-import/reauth) that cleared the marker supersedes the
1153
+ # deletion, so touch nothing. The variant comes from the
1154
+ # persisted flag frozen at request time, never the caller.
1155
+ # On PostgreSQL the identity-membership row lock held above
1156
+ # keeps the marker stable through this transaction; on SQLite
1157
+ # the writer section serializes all writers.
1158
+ if pending_state is None or pending_state[0] is None:
1159
+ await self._session.rollback()
1160
+ return False
1161
+ if credentials_replaced_since_wipe(pending_state[2], pending_state[3], pending_state[4]):
1162
+ # A pre-upgrade replica replaced the credentials without
1163
+ # being able to clear marker columns its ORM does not
1164
+ # know. That replacement supersedes the deletion: clear
1165
+ # the marker (we hold the row lock) and abandon.
1166
+ await self._session.execute(
1167
+ update(Account)
1168
+ .where(Account.id == account_id)
1169
+ .values(delete_requested_at=None, delete_history_requested=False)
1170
+ )
1171
+ await self._session.commit()
1172
+ return False
1173
+ delete_history = bool(pending_state[1])
1174
+ # Serialize against fold passes before touching the account's
1175
+ # request logs: without the fold-state lock an in-flight hourly
1176
+ # slice could aggregate the pre-delete attribution but commit
1177
+ # after this transaction, resurrecting the account's folded rows
1178
+ # the mirrors below just moved or removed.
1179
+ await lock_fold_state(self._session)
1180
+ if self._dialect_name() == "postgresql":
1181
+ # Upgrade the account row to a full FOR UPDATE lock BEFORE the
1182
+ # raw sweeps. FOR UPDATE conflicts with the KEY SHARE taken by
1183
+ # concurrent request-log FK inserts, so every in-flight
1184
+ # stream's log row either commits before this point (and the
1185
+ # sweeps below see it) or its insert blocks until this
1186
+ # transaction commits and then fails its FK against the
1187
+ # deleted row — the same outcome a post-delete insert always
1188
+ # had. Without the upgrade, an insert could commit between
1189
+ # the sweep and the account-row delete: the FK's ON DELETE
1190
+ # SET NULL would leave a live (deleted_at IS NULL) orphan on
1191
+ # the soft path, or surviving raw history under
1192
+ # delete_history. Lock order (identity -> fold -> row
1193
+ # exclusive) matches the historical transaction, where the
1194
+ # final DELETE acquired this same exclusive lock after the
1195
+ # fold lock.
1196
+ await self._session.execute(select(Account.id).where(Account.id == account_id).with_for_update())
1197
+ await self._session.execute(delete(UsageHistory).where(UsageHistory.account_id == account_id))
1198
+ if delete_history:
1199
+ await self._session.execute(delete(RequestLog).where(RequestLog.account_id == account_id))
1200
+ # Mirror the raw DELETE into the folded time-axis buckets;
1201
+ # raw below the watermark may already be pruned, so folded
1202
+ # rows can never be recomputed and must be removed directly.
1203
+ await mirror_account_hard_delete_into_time_rollups(self._session, account_id)
1204
+ else:
1205
+ await self._session.execute(
1206
+ update(RequestLog)
1207
+ .where(RequestLog.account_id == account_id)
1208
+ .values(account_id=None, deleted_at=utcnow()),
1209
+ )
1210
+ # Mirror the retroactive detach (account_id=NULL, deleted_at
1211
+ # set) into the folded buckets: move them to the orphaned
1212
+ # deleted dimension so time-series totals are preserved.
1213
+ await mirror_account_soft_delete_into_time_rollups(self._session, account_id)
1214
+ await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
1215
+ await self._session.execute(delete(AccountUsageRollup).where(AccountUsageRollup.account_id == account_id))
1216
+ result = await self._session.execute(delete(Account).where(Account.id == account_id).returning(Account.id))
1217
+ deleted_id = result.scalar_one_or_none()
1218
+ await self._session.commit()
1219
+ if deleted_id is not None:
1220
+ _clear_bulk_history_since_sqlite_cache()
1221
+ # Deletion drops the account's rollup row and detaches or
1222
+ # deletes its request logs; cached listing summaries would
1223
+ # keep reporting the account until TTL expiry.
1224
+ _clear_request_usage_summary_cache()
1225
+ return deleted_id is not None
1226
+
1227
+ async def rotate_tokens(
1228
+ self,
1229
+ account_id: str,
1230
+ access_token_encrypted: bytes,
1231
+ refresh_token_encrypted: bytes,
1232
+ id_token_encrypted: bytes,
1233
+ last_refresh: datetime,
1234
+ *,
1235
+ expected_refresh_token_encrypted: bytes,
1236
+ plan_type: str | None = None,
1237
+ email: str | None = None,
1238
+ chatgpt_account_id: str | None = None,
1239
+ chatgpt_user_id: str | None = None,
1240
+ workspace_id: str | None = None,
1241
+ workspace_label: str | None = None,
1242
+ seat_type: str | None = None,
1243
+ ) -> bool:
1244
+ """Persist rotated access/refresh/id token ciphertext under a mandatory
1245
+ compare-and-set on the refresh-token ciphertext.
1246
+
1247
+ This is the ONLY method that writes token ciphertext, and the CAS
1248
+ predicate (``expected_refresh_token_encrypted``) is REQUIRED — there is
1249
+ no code path that writes ``refresh_token_encrypted`` unconditionally.
1250
+ The comparison is atomic in the database, so a concurrent rotation
1251
+ committed after the caller read ``expected`` makes this write MISS (it
1252
+ returns ``False`` and touches no row) rather than clobbering the peer's
1253
+ fresh material. Metadata is co-written here only because a genuine
1254
+ rotation carries a fresh identity/plan/workspace snapshot; metadata-only
1255
+ writers must use ``update_account_metadata`` (which cannot touch token
1256
+ material at all).
1257
+ """
1258
+ async with sqlite_writer_section():
1259
+ if self._dialect_name() == "postgresql":
1260
+ await self._lock_postgresql_account_identity_membership(account_id, chatgpt_account_id)
1261
+ values: dict[str, bytes | datetime | str] = {
1262
+ "access_token_encrypted": access_token_encrypted,
1263
+ "refresh_token_encrypted": refresh_token_encrypted,
1264
+ "id_token_encrypted": id_token_encrypted,
1265
+ "last_refresh": last_refresh,
1266
+ }
1267
+ if plan_type is not None:
1268
+ values["plan_type"] = plan_type
1269
+ if email is not None:
1270
+ values["email"] = email
1271
+ if chatgpt_account_id is not None:
1272
+ values["chatgpt_account_id"] = chatgpt_account_id
1273
+ if chatgpt_user_id is not None:
1274
+ values["chatgpt_user_id"] = chatgpt_user_id
1275
+ if workspace_id is not None:
1276
+ values["workspace_id"] = workspace_id
1277
+ if workspace_label is not None:
1278
+ values["workspace_label"] = workspace_label
1279
+ if seat_type is not None:
1280
+ values["seat_type"] = seat_type
1281
+ stmt = (
1282
+ update(Account)
1283
+ .where(Account.id == account_id)
1284
+ # Compare-and-set on the exact ciphertext the caller read before
1285
+ # the upstream exchange, so a concurrent rotation is never
1286
+ # clobbered by a slower writer.
1287
+ .where(Account.refresh_token_encrypted == expected_refresh_token_encrypted)
1288
+ .values(**values)
1289
+ .returning(Account.id)
1290
+ )
1291
+ result = await self._session.execute(stmt)
1292
+ await self._session.commit()
1293
+ return result.scalar_one_or_none() is not None
1294
+
1295
+ async def update_account_metadata(
1296
+ self,
1297
+ account_id: str,
1298
+ *,
1299
+ plan_type: str | None = None,
1300
+ email: str | None = None,
1301
+ chatgpt_account_id: str | None = None,
1302
+ chatgpt_user_id: str | None = None,
1303
+ workspace_id: str | None = None,
1304
+ workspace_label: str | None = None,
1305
+ seat_type: str | None = None,
1306
+ last_refresh: datetime | None = None,
1307
+ ) -> bool:
1308
+ """Update non-token account metadata (identity/plan/workspace fields).
1309
+
1310
+ This method structurally CANNOT write access/refresh/id token
1311
+ ciphertext — there is no parameter for it — so a metadata-only writer
1312
+ holding a stale ``Account`` snapshot can never clobber a concurrent
1313
+ cross-replica token rotation. It is the correct path for backfills and
1314
+ identity syncs that must never touch token material. Only the provided
1315
+ (non-``None``) fields are written; a call with nothing to set is a
1316
+ no-op existence check.
1317
+ """
1318
+ async with sqlite_writer_section():
1319
+ if self._dialect_name() == "postgresql":
1320
+ await self._lock_postgresql_account_identity_membership(account_id, chatgpt_account_id)
1321
+ values: dict[str, str | datetime] = {}
1322
+ if plan_type is not None:
1323
+ values["plan_type"] = plan_type
1324
+ if email is not None:
1325
+ values["email"] = email
1326
+ if chatgpt_account_id is not None:
1327
+ values["chatgpt_account_id"] = chatgpt_account_id
1328
+ if chatgpt_user_id is not None:
1329
+ values["chatgpt_user_id"] = chatgpt_user_id
1330
+ if workspace_id is not None:
1331
+ values["workspace_id"] = workspace_id
1332
+ if workspace_label is not None:
1333
+ values["workspace_label"] = workspace_label
1334
+ if seat_type is not None:
1335
+ values["seat_type"] = seat_type
1336
+ if last_refresh is not None:
1337
+ values["last_refresh"] = last_refresh
1338
+ if not values:
1339
+ existing = await self._session.get(Account, account_id)
1340
+ return existing is not None
1341
+ result = await self._session.execute(
1342
+ update(Account).where(Account.id == account_id).values(**values).returning(Account.id)
1343
+ )
1344
+ await self._session.commit()
1345
+ return result.scalar_one_or_none() is not None
1346
+
1347
+ async def workspace_slot_taken(
1348
+ self,
1349
+ *,
1350
+ account_id: str,
1351
+ email: str,
1352
+ chatgpt_account_id: str | None,
1353
+ workspace_id: str,
1354
+ ) -> bool:
1355
+ if chatgpt_account_id:
1356
+ predicates = [
1357
+ (Account.chatgpt_account_id == chatgpt_account_id) & (Account.workspace_id == workspace_id),
1358
+ (
1359
+ (Account.email == email)
1360
+ & (Account.workspace_id == workspace_id)
1361
+ & Account.chatgpt_account_id.is_(None)
1362
+ ),
1363
+ ]
1364
+ else:
1365
+ predicates = [(Account.email == email) & (Account.workspace_id == workspace_id)]
1366
+ result = await self._session.execute(
1367
+ select(Account.id).where(Account.id != account_id).where(or_(*predicates)).limit(1)
1368
+ )
1369
+ return result.scalar_one_or_none() is not None
1370
+
1371
+ async def _merge_by_email_enabled(self) -> bool:
1372
+ settings = await self._session.get(DashboardSettings, _SETTINGS_ROW_ID)
1373
+ if settings is None:
1374
+ return True
1375
+ return not settings.import_without_overwrite
1376
+
1377
+ async def additional_quota_routing_policy_overrides(self) -> dict[str, str]:
1378
+ settings = await self._session.get(DashboardSettings, _SETTINGS_ROW_ID)
1379
+ if settings is None or not settings.additional_quota_routing_policies_json:
1380
+ return {}
1381
+ try:
1382
+ parsed = json.loads(settings.additional_quota_routing_policies_json)
1383
+ except json.JSONDecodeError:
1384
+ return {}
1385
+ if not isinstance(parsed, dict):
1386
+ return {}
1387
+ string_policies = {
1388
+ key: value for key, value in parsed.items() if isinstance(key, str) and isinstance(value, str)
1389
+ }
1390
+ return normalize_additional_quota_routing_policy_overrides(string_policies)
1391
+
1392
+ async def _next_available_account_id(self, base_id: str) -> str:
1393
+ candidate = base_id
1394
+ sequence = 2
1395
+ while await self._session.get(Account, candidate) is not None:
1396
+ candidate = f"{base_id}{_DUPLICATE_ACCOUNT_SUFFIX}{sequence}"
1397
+ sequence += 1
1398
+ return candidate
1399
+
1400
+ async def _single_account_by_email(self, email: str) -> Account | None:
1401
+ result = await self._session.execute(
1402
+ select(Account).where(Account.email == email).order_by(Account.created_at.asc(), Account.id.asc()).limit(2)
1403
+ )
1404
+ matches = list(result.scalars().all())
1405
+ if not matches:
1406
+ return None
1407
+ if len(matches) > 1:
1408
+ raise AccountIdentityConflictError(email)
1409
+ return matches[0]
1410
+
1411
+ async def _single_unknown_workspace_account_by_email(self, email: str) -> Account | None:
1412
+ result = await self._session.execute(
1413
+ select(Account)
1414
+ .where(Account.email == email)
1415
+ .where(Account.workspace_id.is_(None))
1416
+ .where(Account.workspace_label.is_(None))
1417
+ .order_by(Account.created_at.asc(), Account.id.asc())
1418
+ .limit(2)
1419
+ )
1420
+ matches = list(result.scalars().all())
1421
+ if not matches:
1422
+ return None
1423
+ if len(matches) > 1:
1424
+ raise AccountIdentityConflictError(email)
1425
+ return matches[0]
1426
+
1427
+ async def _account_by_slot_identity(self, account: Account) -> Account | None:
1428
+ workspace_slot = _workspace_slot_identity(account)
1429
+ if account.chatgpt_account_id and account.email and workspace_slot:
1430
+ column, value = workspace_slot
1431
+ result = await self._session.execute(
1432
+ select(Account)
1433
+ .where(Account.chatgpt_account_id == account.chatgpt_account_id)
1434
+ .where(Account.email == account.email)
1435
+ .where(column == value)
1436
+ .order_by(Account.created_at.asc(), Account.id.asc())
1437
+ .limit(1)
1438
+ )
1439
+ if matched := result.scalar_one_or_none():
1440
+ return matched
1441
+ if account.chatgpt_account_id and account.email and account.workspace_id and account.workspace_label:
1442
+ result = await self._session.execute(
1443
+ select(Account)
1444
+ .where(Account.chatgpt_account_id == account.chatgpt_account_id)
1445
+ .where(Account.email == account.email)
1446
+ .where(Account.workspace_id.is_(None))
1447
+ .where(Account.workspace_label == account.workspace_label)
1448
+ .order_by(Account.created_at.asc(), Account.id.asc())
1449
+ .limit(1)
1450
+ )
1451
+ if matched := result.scalar_one_or_none():
1452
+ return matched
1453
+ if workspace_slot and account.email:
1454
+ column, value = workspace_slot
1455
+ result = await self._session.execute(
1456
+ select(Account)
1457
+ .where(Account.email == account.email)
1458
+ .where(column == value)
1459
+ .order_by(Account.created_at.asc(), Account.id.asc())
1460
+ .limit(1)
1461
+ )
1462
+ matched = result.scalar_one_or_none()
1463
+ if matched is not None and _can_reuse_email_fallback(matched, account):
1464
+ return matched
1465
+ return None
1466
+
1467
+ async def _lock_postgresql_account_identity_membership(
1468
+ self,
1469
+ account_id: str,
1470
+ incoming_chatgpt_account_id: str | None,
1471
+ *,
1472
+ second_attempt: bool = False,
1473
+ ) -> Account | None:
1474
+ """Lock one row's old/new upstream memberships before mutating it."""
1475
+ observed_identity = await self._session.scalar(
1476
+ select(Account.chatgpt_account_id).where(Account.id == account_id)
1477
+ )
1478
+ await lock_postgresql_account_identities(
1479
+ self._session,
1480
+ (observed_identity, incoming_chatgpt_account_id),
1481
+ )
1482
+ locked_account = await self._session.scalar(
1483
+ select(Account)
1484
+ .where(Account.id == account_id)
1485
+ # PostgreSQL FOR NO KEY UPDATE stabilizes identity membership but
1486
+ # remains compatible with the KEY SHARE lock taken by concurrent
1487
+ # rollup FK inserts. Deletion upgrades only after the fold lock.
1488
+ .with_for_update(key_share=True)
1489
+ .execution_options(populate_existing=True)
1490
+ )
1491
+ locked_identity = locked_account.chatgpt_account_id if locked_account is not None else None
1492
+ if locked_identity == observed_identity:
1493
+ return locked_account
1494
+ await self._session.rollback()
1495
+ if second_attempt:
1496
+ raise AccountIdentityRelockError("Account identity changed during PostgreSQL membership lock acquisition")
1497
+ return await self._lock_postgresql_account_identity_membership(
1498
+ account_id,
1499
+ incoming_chatgpt_account_id,
1500
+ second_attempt=True,
1501
+ )
1502
+
1503
+ async def _lock_postgresql_upsert_identity_candidates(
1504
+ self,
1505
+ account: Account,
1506
+ *,
1507
+ include_email: bool,
1508
+ ) -> frozenset[str]:
1509
+ predicates = _upsert_identity_candidate_predicates(account, include_email=include_email)
1510
+ observed = (
1511
+ (await self._session.execute(select(Account.chatgpt_account_id).where(or_(*predicates)))).scalars().all()
1512
+ )
1513
+ identities = frozenset(identity for identity in (*observed, account.chatgpt_account_id) if identity)
1514
+ await lock_postgresql_account_identities(self._session, identities)
1515
+ return identities
1516
+
1517
+ async def _postgresql_upsert_identity_candidates_are_locked(
1518
+ self,
1519
+ account: Account,
1520
+ *,
1521
+ include_email: bool,
1522
+ locked_identities: frozenset[str],
1523
+ ) -> bool:
1524
+ predicates = _upsert_identity_candidate_predicates(account, include_email=include_email)
1525
+ current = (
1526
+ (
1527
+ await self._session.execute(
1528
+ select(Account.chatgpt_account_id).where(or_(*predicates)).with_for_update(key_share=True)
1529
+ )
1530
+ )
1531
+ .scalars()
1532
+ .all()
1533
+ )
1534
+ return all(identity is None or identity in locked_identities for identity in current)
1535
+
1536
+ def _dialect_name(self) -> str:
1537
+ return self._session.get_bind().dialect.name
1538
+
1539
+ async def _acquire_sqlite_merge_lock(self) -> None:
1540
+ try:
1541
+ await self._session.execute(text("BEGIN IMMEDIATE"))
1542
+ except OperationalError as exc:
1543
+ message = str(exc).lower()
1544
+ if "within a transaction" not in message:
1545
+ raise
1546
+ # A no-op write escalates the current deferred transaction to a write
1547
+ # transaction, serializing concurrent writers.
1548
+ await self._session.execute(text("UPDATE accounts SET id = id WHERE 1 = 0"))
1549
+
1550
+ async def _acquire_postgresql_merge_lock(self, email: str) -> None:
1551
+ lock_key = advisory_lock_key("merge-email", email)
1552
+ await self._session.execute(
1553
+ text("SELECT pg_advisory_xact_lock(:lock_key)"),
1554
+ {"lock_key": lock_key},
1555
+ )
1556
+
1557
+ async def _acquire_postgresql_identity_lock(self, account_id: str) -> None:
1558
+ lock_key = advisory_lock_key("account-id", account_id)
1559
+ await self._session.execute(
1560
+ text("SELECT pg_advisory_xact_lock(:lock_key)"),
1561
+ {"lock_key": lock_key},
1562
+ )
1563
+
1564
+
1565
+ def _apply_account_updates(target: Account, source: Account) -> None:
1566
+ if source.chatgpt_account_id is not None:
1567
+ target.chatgpt_account_id = source.chatgpt_account_id
1568
+ if source.chatgpt_user_id is not None:
1569
+ target.chatgpt_user_id = source.chatgpt_user_id
1570
+ target.email = source.email
1571
+ if source.workspace_id is not None or target.workspace_id is None:
1572
+ target.workspace_id = source.workspace_id
1573
+ target.workspace_label = source.workspace_label
1574
+ target.seat_type = source.seat_type
1575
+ if not target.codex_installation_id:
1576
+ target.codex_installation_id = source.codex_installation_id or str(uuid.uuid4())
1577
+ target.plan_type = source.plan_type
1578
+ target.access_token_encrypted = source.access_token_encrypted
1579
+ target.refresh_token_encrypted = source.refresh_token_encrypted
1580
+ target.id_token_encrypted = source.id_token_encrypted
1581
+ target.last_refresh = source.last_refresh
1582
+ target.status = source.status
1583
+ target.deactivation_reason = source.deactivation_reason
1584
+ target.reset_at = source.reset_at
1585
+ target.blocked_at = source.blocked_at
1586
+ # A credential replacement (re-import/reauth) supersedes a pending
1587
+ # background deletion: clearing the marker makes the deletion worker
1588
+ # abandon the account before finalizing (rows already drained stay
1589
+ # detached — history loss was requested by the earlier delete).
1590
+ target.delete_requested_at = None
1591
+ target.delete_history_requested = False
1592
+
1593
+
1594
+ def _slot_lock_key(account: Account, *, preserve_unknown_workspace_duplicates: bool = True) -> str:
1595
+ return _slot_lock_keys(
1596
+ account,
1597
+ preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
1598
+ )[0]
1599
+
1600
+
1601
+ def _slot_lock_keys(account: Account, *, preserve_unknown_workspace_duplicates: bool = True) -> tuple[str, ...]:
1602
+ keys: list[str] = []
1603
+ workspace_key = _workspace_slot_key(account)
1604
+ if account.chatgpt_account_id:
1605
+ if workspace_key:
1606
+ keys.append(f"slot:{account.chatgpt_account_id}:{workspace_key}")
1607
+ elif account.email:
1608
+ keys.append(f"slot:{account.chatgpt_account_id}:{account.email}")
1609
+ if account.email and workspace_key:
1610
+ keys.append(f"slot-email:{account.email}:{workspace_key}")
1611
+ if not preserve_unknown_workspace_duplicates:
1612
+ keys.append(f"slot-email-unknown:{account.email}")
1613
+ if keys:
1614
+ return tuple(keys)
1615
+ if account.email and not preserve_unknown_workspace_duplicates:
1616
+ return (f"slot-email-unknown:{account.email}",)
1617
+ return (f"slot-local:{account.id}",)
1618
+
1619
+
1620
+ def _upsert_identity_candidate_predicates(account: Account, *, include_email: bool) -> list[Any]:
1621
+ predicates = [Account.id == account.id]
1622
+ if account.chatgpt_account_id:
1623
+ predicates.append(Account.chatgpt_account_id == account.chatgpt_account_id)
1624
+ if include_email and account.email:
1625
+ predicates.append(Account.email == account.email)
1626
+ return predicates
1627
+
1628
+
1629
+ def _same_unknown_workspace_identity(existing: Account, incoming: Account) -> bool:
1630
+ return (
1631
+ _workspace_slot_key(existing) is None
1632
+ and _workspace_slot_key(incoming) is None
1633
+ and existing.chatgpt_account_id == incoming.chatgpt_account_id
1634
+ and existing.email == incoming.email
1635
+ )
1636
+
1637
+
1638
+ def _workspace_slot_identity(account: Account) -> tuple[Any, str] | None:
1639
+ if account.workspace_id:
1640
+ return Account.workspace_id, account.workspace_id
1641
+ if account.workspace_label:
1642
+ return Account.workspace_label, account.workspace_label
1643
+ return None
1644
+
1645
+
1646
+ def _workspace_slot_key(account: Account) -> str | None:
1647
+ if account.workspace_id:
1648
+ return account.workspace_id
1649
+ if account.workspace_label:
1650
+ return account.workspace_label
1651
+ return None
1652
+
1653
+
1654
+ def _is_workspace_less_reauth_for_known_slot(
1655
+ existing: Account,
1656
+ incoming: Account,
1657
+ *,
1658
+ merge_by_chatgpt_identity: bool,
1659
+ ) -> bool:
1660
+ return (
1661
+ merge_by_chatgpt_identity
1662
+ and existing.workspace_id is not None
1663
+ and incoming.workspace_id is None
1664
+ and incoming.chatgpt_account_id is not None
1665
+ and existing.chatgpt_account_id == incoming.chatgpt_account_id
1666
+ and existing.email == incoming.email
1667
+ )
1668
+
1669
+
1670
+ def _can_reuse_email_fallback(existing: Account, incoming: Account) -> bool:
1671
+ existing_workspace_key = _workspace_slot_key(existing)
1672
+ incoming_workspace_key = _workspace_slot_key(incoming)
1673
+ if existing_workspace_key and incoming_workspace_key and existing_workspace_key != incoming_workspace_key:
1674
+ return False
1675
+ return (
1676
+ not incoming.chatgpt_account_id
1677
+ or not existing.chatgpt_account_id
1678
+ or existing.chatgpt_account_id == incoming.chatgpt_account_id
1679
+ )