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,1196 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import html
5
+ import logging
6
+ import secrets
7
+ import time
8
+ from contextlib import AbstractAsyncContextManager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Awaitable, Callable
12
+
13
+ from aiohttp import web
14
+ from sqlalchemy import select
15
+ from sqlalchemy.exc import OperationalError
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from app.core.auth import (
19
+ DEFAULT_EMAIL,
20
+ DEFAULT_PLAN,
21
+ OpenAIAuthClaims,
22
+ clean_account_identity_part,
23
+ extract_id_token_claims,
24
+ generate_unique_account_id,
25
+ normalize_seat_type,
26
+ resolve_seat_identity,
27
+ )
28
+ from app.core.auth.api_key_cache import get_api_key_cache
29
+ from app.core.cache.invalidation import NAMESPACE_API_KEY, get_cache_invalidation_poller
30
+ from app.core.clients.oauth import (
31
+ OAuthError,
32
+ OAuthTokens,
33
+ build_authorization_url,
34
+ exchange_authorization_code,
35
+ exchange_device_token,
36
+ generate_pkce_pair,
37
+ request_device_code,
38
+ )
39
+ from app.core.config.settings import OAUTH_CALLBACK_PORT, OAUTH_REDIRECT_URI, get_settings
40
+ from app.core.crypto import TokenEncryptor
41
+ from app.core.plan_types import coerce_account_plan_type
42
+ from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route
43
+ from app.core.utils.time import naive_utc_to_epoch, utcnow
44
+ from app.db.models import Account, AccountProxyBinding, AccountStatus
45
+ from app.db.session import get_background_session, sqlite_writer_section
46
+ from app.modules.accounts.repository import AccountIdentityConflictError, AccountsRepository
47
+ from app.modules.oauth.repository import (
48
+ OAuthFlowRecord,
49
+ OAuthFlowRepository,
50
+ epoch_to_naive_utc,
51
+ )
52
+ from app.modules.oauth.schemas import (
53
+ ManualCallbackResponse,
54
+ OauthCompleteRequest,
55
+ OauthCompleteResponse,
56
+ OauthStartRequest,
57
+ OauthStartResponse,
58
+ OauthStatusResponse,
59
+ )
60
+ from app.modules.proxy.account_cache import (
61
+ clear_account_routing_unavailable,
62
+ get_account_selection_cache,
63
+ propagate_account_routing_change,
64
+ )
65
+
66
+ _async_sleep = asyncio.sleep
67
+ logger = logging.getLogger(__name__)
68
+ _SUCCESS_TEMPLATE = Path(__file__).resolve().parent / "templates" / "oauth_success.html"
69
+ _TERMINAL_OAUTH_STATUSES = {"error", "success"}
70
+ _MAX_RETAINED_TERMINAL_OAUTH_FLOWS = 16
71
+ _PENDING_BROWSER_OAUTH_FLOW_TTL_SECONDS = 15 * 60
72
+ _ACCOUNT_IDENTITY_CONFLICT_MESSAGE = (
73
+ "Multiple accounts match the authenticated identity. Remove duplicate accounts and retry OAuth."
74
+ )
75
+ _REAUTH_SEAT_MISMATCH_MESSAGE = (
76
+ "The account you signed in as is not the one being re-authenticated. "
77
+ "No changes were made. Sign out of ChatGPT (or use a private window), then re-run "
78
+ "reauthentication and log in as the exact account that needs repair."
79
+ )
80
+
81
+
82
+ async def _has_active_proxy_bindings(session: AsyncSession) -> bool:
83
+ try:
84
+ result = await session.execute(
85
+ select(AccountProxyBinding.id).where(AccountProxyBinding.is_active.is_(True)).limit(1)
86
+ )
87
+ return result.scalar_one_or_none() is not None
88
+ except OperationalError:
89
+ return False
90
+
91
+
92
+ async def _oauth_route() -> ResolvedUpstreamRoute | None:
93
+ async with get_background_session() as session:
94
+ strict = await _has_active_proxy_bindings(session)
95
+ try:
96
+ return await resolve_upstream_route(
97
+ session,
98
+ account_id=None,
99
+ operation="oauth",
100
+ scope="bootstrap",
101
+ # strict=True forces default-pool requirement; None defers to dashboard setting
102
+ strict=strict or None,
103
+ )
104
+ except UpstreamProxyRouteError as exc:
105
+ raise OAuthError(exc.reason, str(exc), status_code=502) from exc
106
+
107
+
108
+ class ReauthSeatMismatchError(Exception):
109
+ """Raised when a targeted reauth callback returns a different seat than intended."""
110
+
111
+ def __init__(self, intended_email: str | None, returned_email: str | None) -> None:
112
+ self.intended_email = intended_email
113
+ self.returned_email = returned_email
114
+ super().__init__(
115
+ "Reauthentication returned a different account than the one being repaired "
116
+ f"(expected {intended_email or 'the selected seat'}, got {returned_email or 'unknown'})."
117
+ )
118
+
119
+
120
+ @dataclass
121
+ class OAuthState:
122
+ flow_id: str | None = None
123
+ status: str = "pending"
124
+ method: str | None = None
125
+ error_message: str | None = None
126
+ state_token: str | None = None
127
+ intended_account_id: str | None = None
128
+ code_verifier: str | None = None
129
+ device_auth_id: str | None = None
130
+ user_code: str | None = None
131
+ interval_seconds: int | None = None
132
+ expires_at: float | None = None
133
+ finished_at: float | None = None
134
+ callback_server: "OAuthCallbackServer | None" = None
135
+ poll_task: asyncio.Task[None] | None = None
136
+
137
+
138
+ class OAuthStateStore:
139
+ def __init__(self) -> None:
140
+ self._lock = asyncio.Lock()
141
+ self._state = OAuthState(status="idle")
142
+ self._flows: dict[str, OAuthState] = {}
143
+ self._state_token_index: dict[str, str] = {}
144
+ self._callback_server: OAuthCallbackServer | None = None
145
+ self._callback_server_stop_task: asyncio.Task[None] | None = None
146
+ self._browser_flow_expiry_task: asyncio.Task[None] | None = None
147
+ self._browser_flow_expiry_changed = asyncio.Event()
148
+
149
+ @property
150
+ def lock(self) -> asyncio.Lock:
151
+ return self._lock
152
+
153
+ @property
154
+ def state(self) -> OAuthState:
155
+ return self._state
156
+
157
+ def get_flow_locked(self, flow_id: str | None) -> OAuthState | None:
158
+ resolved_flow_id = flow_id or self._state.flow_id
159
+ if resolved_flow_id is None:
160
+ return None
161
+ return self._flows.get(resolved_flow_id)
162
+
163
+ def get_flow_by_state_token_locked(self, state_token: str | None) -> OAuthState | None:
164
+ if state_token is None:
165
+ return None
166
+ flow_id = self._state_token_index.get(state_token)
167
+ if flow_id is None:
168
+ return None
169
+ return self._flows.get(flow_id)
170
+
171
+ def remember_flow_locked(self, flow: OAuthState) -> None:
172
+ if flow.flow_id is None:
173
+ raise ValueError("flow_id is required")
174
+ self.prune_expired_pending_browser_flows_locked()
175
+ self._flows[flow.flow_id] = flow
176
+ if flow.state_token is not None:
177
+ self._state_token_index[flow.state_token] = flow.flow_id
178
+ self.set_latest_flow_locked(flow)
179
+
180
+ def set_latest_flow_locked(self, flow: OAuthState) -> None:
181
+ self._state = OAuthState(
182
+ flow_id=flow.flow_id,
183
+ status=flow.status,
184
+ method=flow.method,
185
+ error_message=flow.error_message,
186
+ state_token=flow.state_token,
187
+ code_verifier=flow.code_verifier,
188
+ device_auth_id=flow.device_auth_id,
189
+ user_code=flow.user_code,
190
+ interval_seconds=flow.interval_seconds,
191
+ expires_at=flow.expires_at,
192
+ finished_at=flow.finished_at,
193
+ poll_task=flow.poll_task,
194
+ )
195
+
196
+ def set_flow_status_locked(self, flow: OAuthState, *, status: str, error_message: str | None) -> None:
197
+ flow.status = status
198
+ flow.error_message = error_message
199
+ flow.finished_at = time.time() if status in _TERMINAL_OAUTH_STATUSES else None
200
+ self.set_latest_flow_locked(flow)
201
+ if status in _TERMINAL_OAUTH_STATUSES:
202
+ self.prune_terminal_flows_locked()
203
+
204
+ def has_pending_browser_flows_locked(self) -> bool:
205
+ self.prune_expired_pending_browser_flows_locked()
206
+ return any(flow.method == "browser" and flow.status == "pending" for flow in self._flows.values())
207
+
208
+ def next_pending_browser_flow_expiry_locked(self) -> float | None:
209
+ return min(
210
+ (
211
+ flow.expires_at
212
+ for flow in self._flows.values()
213
+ if flow.method == "browser" and flow.status == "pending" and flow.expires_at is not None
214
+ ),
215
+ default=None,
216
+ )
217
+
218
+ async def reset(self) -> None:
219
+ server: OAuthCallbackServer | None = None
220
+ async with self._lock:
221
+ expiry_task = self._browser_flow_expiry_task
222
+ self._browser_flow_expiry_task = None
223
+ if expiry_task is not None:
224
+ expiry_task.cancel()
225
+ server = self._cleanup_locked()
226
+ self._state = OAuthState(status="idle")
227
+ if expiry_task is not None:
228
+ await asyncio.gather(expiry_task, return_exceptions=True)
229
+ if server is not None:
230
+ await server.stop()
231
+
232
+ def _cleanup_locked(self, *, clear_callback_server: bool = True) -> OAuthCallbackServer | None:
233
+ for flow in self._flows.values():
234
+ task = flow.poll_task
235
+ if task and not task.done():
236
+ task.cancel()
237
+ server = self._callback_server
238
+ if clear_callback_server:
239
+ self._callback_server = None
240
+ self._flows.clear()
241
+ self._state_token_index.clear()
242
+ self._browser_flow_expiry_changed.set()
243
+ return server
244
+
245
+ def prune_terminal_flows_locked(self) -> None:
246
+ terminal_flows = [
247
+ flow
248
+ for flow in self._flows.values()
249
+ if flow.flow_id is not None and flow.status in _TERMINAL_OAUTH_STATUSES
250
+ ]
251
+ extra_count = len(terminal_flows) - _MAX_RETAINED_TERMINAL_OAUTH_FLOWS
252
+ if extra_count <= 0:
253
+ return
254
+
255
+ terminal_flows.sort(key=lambda flow: flow.finished_at or 0)
256
+ for flow in terminal_flows[:extra_count]:
257
+ self.remove_flow_locked(flow)
258
+
259
+ def prune_expired_pending_browser_flows_locked(self) -> None:
260
+ now = time.time()
261
+ expired_flows = [
262
+ flow
263
+ for flow in self._flows.values()
264
+ if flow.method == "browser"
265
+ and flow.status == "pending"
266
+ and flow.expires_at is not None
267
+ and flow.expires_at <= now
268
+ ]
269
+ for flow in expired_flows:
270
+ self.remove_flow_locked(flow)
271
+
272
+ def remove_pending_device_flows_locked(self) -> None:
273
+ pending_device_flows = [
274
+ flow for flow in self._flows.values() if flow.method == "device" and flow.status == "pending"
275
+ ]
276
+ for flow in pending_device_flows:
277
+ task = flow.poll_task
278
+ if task and not task.done():
279
+ task.cancel()
280
+ self.remove_flow_locked(flow)
281
+
282
+ def remove_flow_locked(self, flow: OAuthState) -> None:
283
+ removed_latest = flow.flow_id is not None and flow.flow_id == self._state.flow_id
284
+ if flow.flow_id is not None:
285
+ self._flows.pop(flow.flow_id, None)
286
+ if flow.state_token is not None:
287
+ self._state_token_index.pop(flow.state_token, None)
288
+ if removed_latest:
289
+ self._restore_latest_flow_locked()
290
+
291
+ def _restore_latest_flow_locked(self) -> None:
292
+ if not self._flows:
293
+ self._state = OAuthState(status="idle")
294
+ return
295
+ latest_flow = max(
296
+ self._flows.values(),
297
+ key=lambda flow: flow.finished_at or flow.expires_at or 0,
298
+ )
299
+ self.set_latest_flow_locked(latest_flow)
300
+
301
+
302
+ class OAuthCallbackServer:
303
+ def __init__(
304
+ self,
305
+ handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
306
+ host: str = "127.0.0.1",
307
+ port: int = 1455,
308
+ ) -> None:
309
+ self._handler = handler
310
+ self._host = host
311
+ self._port = port
312
+ self._runner: web.AppRunner | None = None
313
+ self._site: web.TCPSite | None = None
314
+
315
+ async def start(self) -> None:
316
+ app = web.Application()
317
+ app.router.add_get("/auth/callback", self._handler)
318
+ self._runner = web.AppRunner(app, access_log=None)
319
+ await self._runner.setup()
320
+ self._site = web.TCPSite(self._runner, self._host, self._port)
321
+ await self._site.start()
322
+
323
+ async def stop(self) -> None:
324
+ if self._runner:
325
+ await self._runner.cleanup()
326
+ self._runner = None
327
+ self._site = None
328
+
329
+
330
+ _OAUTH_STORE = OAuthStateStore()
331
+
332
+
333
+ class OauthService:
334
+ def __init__(
335
+ self,
336
+ accounts_repo: AccountsRepository,
337
+ repo_factory: Callable[[], AbstractAsyncContextManager[AccountsRepository]] | None = None,
338
+ store: OAuthStateStore | None = None,
339
+ ) -> None:
340
+ self._accounts_repo = accounts_repo
341
+ self._encryptor = TokenEncryptor()
342
+ # ``store`` is the process-local runtime registry (callback server +
343
+ # device poll tasks). It defaults to the module singleton; tests inject
344
+ # a distinct store to simulate a second replica over the shared DB.
345
+ self._store = store if store is not None else _OAUTH_STORE
346
+ self._repo_factory = repo_factory
347
+
348
+ # ------------------------------------------------------------------
349
+ # Shared DB-backed flow persistence (cross-replica source of truth)
350
+ # ------------------------------------------------------------------
351
+
352
+ async def _persist_flow_record(self, record: OAuthFlowRecord) -> None:
353
+ async with get_background_session() as session:
354
+ repo = OAuthFlowRepository(session, self._encryptor)
355
+ await repo.purge_expired(terminal_keep=_MAX_RETAINED_TERMINAL_OAUTH_FLOWS)
356
+ await repo.create(record)
357
+
358
+ async def _claim_device_slot(self, flow_id: str) -> None:
359
+ """Atomically make ``flow_id`` the single current device flow, superseding
360
+ any prior one. Serialized in-process for SQLite; the single UPSERT is
361
+ atomic across replicas/processes on both backends."""
362
+
363
+ async with sqlite_writer_section():
364
+ async with get_background_session() as session:
365
+ await OAuthFlowRepository(session, self._encryptor).claim_device_slot(flow_id)
366
+
367
+ async def _consume_device_slot(self, flow_id: str | None) -> bool:
368
+ """Atomically consume the device slot iff ``flow_id`` still holds it.
369
+
370
+ The poller's point of no return before persisting tokens: returns False
371
+ (and the poller aborts without persisting) when the flow was superseded
372
+ by a newer device start, which atomically UPSERTed the slot to a
373
+ different ``flow_id``."""
374
+
375
+ if flow_id is None:
376
+ return False
377
+ async with sqlite_writer_section():
378
+ async with get_background_session() as session:
379
+ return await OAuthFlowRepository(session, self._encryptor).consume_device_slot(flow_id)
380
+
381
+ async def _persist_flow_status(self, flow_id: str, *, status: str, error_message: str | None) -> bool:
382
+ """Write a durable status transition. Returns whether it was applied; a
383
+ non-success write is rejected (``False``) by the monotonic guard when the
384
+ durable row is already ``success`` (a racing winner committed first)."""
385
+
386
+ async with get_background_session() as session:
387
+ repo = OAuthFlowRepository(session, self._encryptor)
388
+ return await repo.set_status(flow_id, status=status, error_message=error_message)
389
+
390
+ async def _load_flow_record(
391
+ self,
392
+ *,
393
+ flow_id: str | None = None,
394
+ state_token: str | None = None,
395
+ ) -> OAuthFlowRecord | None:
396
+ async with get_background_session() as session:
397
+ repo = OAuthFlowRepository(session, self._encryptor)
398
+ if flow_id is not None:
399
+ return await repo.get_by_flow_id(flow_id)
400
+ if state_token is not None:
401
+ return await repo.get_by_state_token(state_token)
402
+ return None
403
+
404
+ @staticmethod
405
+ def _record_to_state(record: OAuthFlowRecord) -> OAuthState:
406
+ return OAuthState(
407
+ flow_id=record.flow_id,
408
+ status=record.status,
409
+ method=record.method,
410
+ error_message=record.error_message,
411
+ state_token=record.state_token,
412
+ intended_account_id=record.intended_account_id,
413
+ code_verifier=record.code_verifier,
414
+ device_auth_id=record.device_auth_id,
415
+ user_code=record.user_code,
416
+ interval_seconds=record.interval_seconds,
417
+ expires_at=naive_utc_to_epoch(record.expires_at) if record.expires_at is not None else None,
418
+ finished_at=naive_utc_to_epoch(record.finished_at) if record.finished_at is not None else None,
419
+ )
420
+
421
+ async def _reconcile_flow_from_durable(
422
+ self,
423
+ *,
424
+ flow_id: str | None = None,
425
+ state_token: str | None = None,
426
+ ) -> OAuthFlowRecord | None:
427
+ """Single authoritative reconciliation gate: make the local in-memory
428
+ ``OAuthState`` agree with the shared DB BEFORE any local-state-based
429
+ decision, and return the durable record.
430
+
431
+ The shared DB always wins over a local ``pending``. This one gate closes
432
+ the whole class of "origin replica acts on stale local state" bugs by
433
+ unifying hydrate / terminal-reconcile / expiry-prune. Every entry point
434
+ that resolves a flow from local state MUST call this first:
435
+
436
+ - durable terminal (``success``/``error``) OVERRIDES a local ``pending``
437
+ (the flow was completed here or on another replica), so no consumed
438
+ authorization code is replayed and no stale pending is reported;
439
+ - durable ``pending`` present but missing locally -> HYDRATE it (encrypted
440
+ verifier + metadata) so this replica can resume/complete a flow it did
441
+ not start;
442
+ - durable row ABSENT or EXPIRED (``get_by_*`` returns ``None`` for an
443
+ expired pending row) -> DROP any local non-terminal flow so its cached
444
+ verifier / device code can never be reused past the TTL.
445
+ """
446
+
447
+ record = await self._load_flow_record(flow_id=flow_id, state_token=state_token)
448
+ async with self._store.lock:
449
+ local = self._store.get_flow_locked(flow_id) if flow_id is not None else None
450
+ if local is None and state_token is not None:
451
+ local = self._store.get_flow_by_state_token_locked(state_token)
452
+ if record is None:
453
+ # No live durable row (absent or expired-pending): a local
454
+ # non-terminal flow is stale and MUST NOT be acted on.
455
+ if local is not None and local.status not in _TERMINAL_OAUTH_STATUSES:
456
+ self._store.remove_flow_locked(local)
457
+ return None
458
+ if record.status in _TERMINAL_OAUTH_STATUSES:
459
+ if local is None:
460
+ self._store.remember_flow_locked(self._record_to_state(record))
461
+ elif local.status != record.status:
462
+ self._store.set_flow_status_locked(local, status=record.status, error_message=record.error_message)
463
+ return record
464
+ # Durable pending: hydrate a flow this replica never saw.
465
+ if local is None:
466
+ self._store.remember_flow_locked(self._record_to_state(record))
467
+ if record.method == "browser":
468
+ self._ensure_browser_flow_expiry_task_locked()
469
+ return record
470
+
471
+ async def start_oauth(self, request: OauthStartRequest) -> OauthStartResponse:
472
+ force_method = (request.force_method or "").lower()
473
+ intended_account_id = clean_account_identity_part(request.account_id)
474
+ if not force_method and not intended_account_id:
475
+ accounts = await self._accounts_repo.list_accounts()
476
+ if accounts:
477
+ server: OAuthCallbackServer | None = None
478
+ stop_task: asyncio.Task[None] | None = None
479
+ async with self._store.lock:
480
+ server = self._store._cleanup_locked(clear_callback_server=False)
481
+ self._store._state = OAuthState(status="success")
482
+ if server is not None:
483
+ stop_task = self._start_callback_server_stop_locked(server)
484
+ if server is not None and stop_task is not None:
485
+ await self._finish_callback_server_stop(server, stop_task)
486
+ return OauthStartResponse(method="browser")
487
+
488
+ if force_method == "device":
489
+ if intended_account_id:
490
+ return await self._start_device_flow(intended_account_id=intended_account_id)
491
+ return await self._start_device_flow()
492
+
493
+ try:
494
+ if intended_account_id:
495
+ return await self._start_browser_flow(intended_account_id=intended_account_id)
496
+ return await self._start_browser_flow()
497
+ except OSError:
498
+ if intended_account_id:
499
+ return await self._start_device_flow(intended_account_id=intended_account_id)
500
+ return await self._start_device_flow()
501
+
502
+ async def oauth_status(self, flow_id: str | None = None) -> OauthStatusResponse:
503
+ if flow_id is not None:
504
+ # Durable status is authoritative: the reconciliation gate syncs the
505
+ # local in-memory flow (terminal overrides pending; expired/absent
506
+ # drops the stale local flow) and returns the durable record.
507
+ record = await self._reconcile_flow_from_durable(flow_id=flow_id)
508
+ if record is not None:
509
+ status = record.status if record.status != "idle" else "pending"
510
+ return OauthStatusResponse(status=status, error_message=record.error_message)
511
+ async with self._store.lock:
512
+ state = self._store.get_flow_locked(flow_id)
513
+ if state is None:
514
+ state = self._store.state if flow_id is None else OAuthState(status="pending")
515
+ status = state.status if state.status != "idle" else "pending"
516
+ return OauthStatusResponse(status=status, error_message=state.error_message)
517
+
518
+ async def complete_oauth(self, request: OauthCompleteRequest | None = None) -> OauthCompleteResponse:
519
+ payload = request or OauthCompleteRequest()
520
+ if payload.flow_id is not None:
521
+ # Durable status is authoritative: reconcile local state and report a
522
+ # durable terminal directly (never re-polling a single-use code).
523
+ record = await self._reconcile_flow_from_durable(flow_id=payload.flow_id)
524
+ if record is not None and record.status in _TERMINAL_OAUTH_STATUSES:
525
+ return OauthCompleteResponse(status=record.status)
526
+ async with self._store.lock:
527
+ flow = self._store.get_flow_locked(payload.flow_id)
528
+ state = flow
529
+ if state is None:
530
+ state = self._store.state if payload.flow_id is None else OAuthState(status="pending")
531
+ if payload.device_auth_id and flow is not None:
532
+ flow.device_auth_id = payload.device_auth_id
533
+ if payload.user_code and flow is not None:
534
+ flow.user_code = payload.user_code
535
+ if flow is not None:
536
+ self._store.set_latest_flow_locked(flow)
537
+ if state.method == "device":
538
+ # ``/complete`` only REPORTS device status; it never starts a poll
539
+ # task. The originating replica is the sole device poller (started
540
+ # at ``start``), so a ``/complete`` served on a replica that did
541
+ # not originate the flow reports the durable status (via the gate
542
+ # above) without spawning a duplicate poller for the single-use
543
+ # device code. If the originating replica dies mid-poll the flow
544
+ # simply expires by TTL and the user retries. A targeted query
545
+ # (explicit ``flow_id``, sent by the dashboard after a success
546
+ # status) reports a terminal so the UI can invalidate; the
547
+ # fire-and-forget acknowledgement (no ``flow_id``) returns
548
+ # ``pending`` while the sole poller runs, even if this replica's
549
+ # own poller just raced to a terminal in-memory.
550
+ if state.status in _TERMINAL_OAUTH_STATUSES and payload.flow_id is not None:
551
+ return OauthCompleteResponse(status=state.status)
552
+ return OauthCompleteResponse(status="pending")
553
+ if state.status == "success":
554
+ # Browser / manual-callback: report an observed success.
555
+ return OauthCompleteResponse(status="success")
556
+ return OauthCompleteResponse(status="pending")
557
+
558
+ async def _start_browser_flow(self, *, intended_account_id: str | None = None) -> OauthStartResponse:
559
+ await self._wait_for_callback_server_stop()
560
+
561
+ flow_id = secrets.token_urlsafe(12)
562
+ code_verifier, code_challenge = generate_pkce_pair()
563
+ state_token = secrets.token_urlsafe(16)
564
+ authorization_url = build_authorization_url(state=state_token, code_challenge=code_challenge)
565
+ settings = get_settings()
566
+ callback_server: OAuthCallbackServer | None = None
567
+
568
+ expires_at = time.time() + _PENDING_BROWSER_OAUTH_FLOW_TTL_SECONDS
569
+ async with self._store.lock:
570
+ self._store.remember_flow_locked(
571
+ OAuthState(
572
+ flow_id=flow_id,
573
+ status="pending",
574
+ method="browser",
575
+ state_token=state_token,
576
+ code_verifier=code_verifier,
577
+ intended_account_id=intended_account_id,
578
+ expires_at=expires_at,
579
+ )
580
+ )
581
+ if self._store._callback_server is None:
582
+ callback_server = OAuthCallbackServer(
583
+ self._handle_callback,
584
+ host=settings.oauth_callback_host,
585
+ port=OAUTH_CALLBACK_PORT,
586
+ )
587
+ self._store._callback_server = callback_server
588
+
589
+ await self._persist_flow_record(
590
+ OAuthFlowRecord(
591
+ flow_id=flow_id,
592
+ method="browser",
593
+ status="pending",
594
+ state_token=state_token,
595
+ code_verifier=code_verifier,
596
+ intended_account_id=intended_account_id,
597
+ expires_at=epoch_to_naive_utc(expires_at),
598
+ )
599
+ )
600
+
601
+ if callback_server is not None:
602
+ try:
603
+ await callback_server.start()
604
+ except OSError:
605
+ async with self._store.lock:
606
+ if self._store._callback_server is callback_server:
607
+ self._store._callback_server = None
608
+
609
+ async with self._store.lock:
610
+ self._ensure_browser_flow_expiry_task_locked()
611
+
612
+ return OauthStartResponse(
613
+ flow_id=flow_id,
614
+ method="browser",
615
+ authorization_url=authorization_url,
616
+ callback_url=OAUTH_REDIRECT_URI,
617
+ )
618
+
619
+ def _ensure_browser_flow_expiry_task_locked(self) -> None:
620
+ """Wake the store's single timer, including for an earlier hydrated TTL."""
621
+ self._store._browser_flow_expiry_changed.set()
622
+ task = self._store._browser_flow_expiry_task
623
+ if task is None or task.done():
624
+ self._store._browser_flow_expiry_task = asyncio.create_task(self._expire_browser_flows())
625
+
626
+ async def _expire_browser_flows(self) -> None:
627
+ """Run existing idle-listener cleanup at pending browser deadlines."""
628
+ current = asyncio.current_task()
629
+ changed = self._store._browser_flow_expiry_changed
630
+ try:
631
+ while True:
632
+ async with self._store.lock:
633
+ expires_at = self._store.next_pending_browser_flow_expiry_locked()
634
+ if expires_at is None:
635
+ self._store._browser_flow_expiry_task = None
636
+ return
637
+ changed.clear()
638
+ try:
639
+ await asyncio.wait_for(changed.wait(), timeout=max(0, expires_at - time.time()))
640
+ except TimeoutError:
641
+ await self._stop_callback_server_if_idle()
642
+ except Exception:
643
+ logger.exception("Browser OAuth expiry cleanup failed")
644
+ finally:
645
+ async with self._store.lock:
646
+ if self._store._browser_flow_expiry_task is current:
647
+ self._store._browser_flow_expiry_task = None
648
+
649
+ async def manual_callback(self, callback_url: str, flow_id: str | None = None) -> ManualCallbackResponse:
650
+ """Process an OAuth callback URL pasted manually by the user.
651
+
652
+ This is useful when the server is accessed remotely and the
653
+ OAuth callback (localhost:1455) is not reachable from the
654
+ user's browser.
655
+ """
656
+ from urllib.parse import parse_qs, urlparse
657
+
658
+ parsed = urlparse(callback_url)
659
+ params = parse_qs(parsed.query)
660
+
661
+ error = params.get("error", [None])[0]
662
+ code = params.get("code", [None])[0]
663
+ state = params.get("state", [None])[0]
664
+
665
+ if state is not None:
666
+ # Durable status is authoritative: the reconciliation gate hydrates
667
+ # (verifier + metadata) when this replica never saw the flow, and --
668
+ # critically -- overrides a stale local ``pending`` with a durable
669
+ # terminal and drops an expired/absent flow. This closes the callback
670
+ # -replay class: a pasted callback for a flow already completed on
671
+ # another replica never re-exchanges the consumed authorization code.
672
+ await self._reconcile_flow_from_durable(state_token=state)
673
+
674
+ async with self._store.lock:
675
+ flow = self._store.get_flow_by_state_token_locked(state)
676
+ verifier = flow.code_verifier if flow is not None else None
677
+ target_flow_id = flow.flow_id if flow is not None else flow_id
678
+ can_update_error = target_flow_id is not None
679
+ if flow_id is not None and (flow is None or flow.flow_id != flow_id):
680
+ flow = None
681
+ verifier = None
682
+ target_flow_id = None
683
+ can_update_error = False
684
+ # Durable terminal wins: return the recorded outcome instead of
685
+ # replaying the (already-consumed) code.
686
+ terminal_status = flow.status if (flow is not None and flow.status in _TERMINAL_OAUTH_STATUSES) else None
687
+ terminal_error = flow.error_message if flow is not None else None
688
+
689
+ if terminal_status is not None:
690
+ if terminal_status == "success":
691
+ return ManualCallbackResponse(status="success")
692
+ return ManualCallbackResponse(status="error", error_message=terminal_error)
693
+
694
+ if error:
695
+ message = f"OAuth error: {error}"
696
+ if can_update_error and await self._finalize_callback_error(message, flow_id=target_flow_id) == "success":
697
+ return ManualCallbackResponse(status="success")
698
+ return ManualCallbackResponse(status="error", error_message=message)
699
+
700
+ if not code or not state or flow is None or not verifier:
701
+ message = "Invalid OAuth callback: state mismatch or missing code."
702
+ if can_update_error and await self._finalize_callback_error(message, flow_id=target_flow_id) == "success":
703
+ return ManualCallbackResponse(status="success")
704
+ return ManualCallbackResponse(status="error", error_message=message)
705
+
706
+ try:
707
+ route = await _oauth_route()
708
+ tokens = await exchange_authorization_code(
709
+ code=code,
710
+ code_verifier=verifier,
711
+ route=route,
712
+ allow_direct_egress=route is None,
713
+ )
714
+ await self._persist_tokens(tokens, intended_account_id=flow.intended_account_id)
715
+ await self._set_success(flow.flow_id)
716
+ asyncio.create_task(self._stop_callback_server_if_idle())
717
+ return ManualCallbackResponse(status="success")
718
+ except OAuthError as exc:
719
+ # Loser race: a concurrent callback may have committed success for the
720
+ # same single-use code, so honor the durable success on a rejected
721
+ # error write instead of surfacing this ``invalid_grant``.
722
+ if await self._finalize_callback_error(exc.message, flow_id=flow.flow_id) == "success":
723
+ return ManualCallbackResponse(status="success")
724
+ return ManualCallbackResponse(status="error", error_message=exc.message)
725
+ except ReauthSeatMismatchError:
726
+ if await self._finalize_callback_error(_REAUTH_SEAT_MISMATCH_MESSAGE, flow_id=flow.flow_id) == "success":
727
+ return ManualCallbackResponse(status="success")
728
+ return ManualCallbackResponse(status="error", error_message=_REAUTH_SEAT_MISMATCH_MESSAGE)
729
+ except AccountIdentityConflictError:
730
+ if (
731
+ await self._finalize_callback_error(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE, flow_id=flow.flow_id)
732
+ == "success"
733
+ ):
734
+ return ManualCallbackResponse(status="success")
735
+ return ManualCallbackResponse(status="error", error_message=_ACCOUNT_IDENTITY_CONFLICT_MESSAGE)
736
+ except Exception as exc:
737
+ logger.error("manual OAuth callback failed exception_type=%s", type(exc).__name__)
738
+ message = "An internal error occurred."
739
+ if await self._finalize_callback_error(message, flow_id=flow.flow_id) == "success":
740
+ return ManualCallbackResponse(status="success")
741
+ return ManualCallbackResponse(status="error", error_message=message)
742
+
743
+ async def _start_device_flow(self, *, intended_account_id: str | None = None) -> OauthStartResponse:
744
+ flow_id = secrets.token_urlsafe(12)
745
+ try:
746
+ route = await _oauth_route()
747
+ device = await request_device_code(route=route, allow_direct_egress=route is None)
748
+ except OAuthError as exc:
749
+ await self._set_error(exc.message)
750
+ raise
751
+
752
+ expires_at = time.time() + device.expires_in_seconds
753
+ flow = OAuthState(
754
+ flow_id=flow_id,
755
+ status="pending",
756
+ method="device",
757
+ device_auth_id=device.device_auth_id,
758
+ user_code=device.user_code,
759
+ interval_seconds=device.interval_seconds,
760
+ intended_account_id=intended_account_id,
761
+ expires_at=expires_at,
762
+ )
763
+ async with self._store.lock:
764
+ self._store.remove_pending_device_flows_locked()
765
+ self._store.remember_flow_locked(flow)
766
+
767
+ # Persist the durable row BEFORE claiming the single-active slot and
768
+ # starting the sole poll task, so the poller's slot consume never races a
769
+ # not-yet-written row.
770
+ await self._persist_flow_record(
771
+ OAuthFlowRecord(
772
+ flow_id=flow_id,
773
+ method="device",
774
+ status="pending",
775
+ device_auth_id=device.device_auth_id,
776
+ user_code=device.user_code,
777
+ interval_seconds=device.interval_seconds,
778
+ intended_account_id=intended_account_id,
779
+ expires_at=epoch_to_naive_utc(expires_at),
780
+ )
781
+ )
782
+
783
+ async with self._store.lock:
784
+ # A later device start on THIS replica may have superseded this flow
785
+ # while its row was being persisted (the newer start removed it from
786
+ # the local store). Claim the single-active slot and start the sole
787
+ # poll task ONLY if this is still the current local device flow, and
788
+ # do so while holding the store lock: the claim then happens under the
789
+ # lock a competing start must also acquire, so claim order follows
790
+ # supersession order and a superseded start can neither install a
791
+ # stale slot pointer nor start a duplicate poller. A superseded start
792
+ # still returns its device code to its caller but does not poll.
793
+ if self._store.get_flow_locked(flow_id) is flow:
794
+ await self._claim_device_slot(flow_id)
795
+ self._ensure_device_poll_task_locked(flow)
796
+
797
+ return OauthStartResponse(
798
+ flow_id=flow_id,
799
+ method="device",
800
+ verification_url=device.verification_url,
801
+ user_code=device.user_code,
802
+ device_auth_id=device.device_auth_id,
803
+ interval_seconds=device.interval_seconds,
804
+ expires_in_seconds=device.expires_in_seconds,
805
+ )
806
+
807
+ async def _handle_callback(self, request: web.Request) -> web.Response:
808
+ params = request.rel_url.query
809
+ error = params.get("error")
810
+ code = params.get("code")
811
+ state = params.get("state")
812
+
813
+ if state is not None:
814
+ # Durable status is authoritative (see manual_callback): reconcile
815
+ # local state before trusting the cached verifier so a browser
816
+ # redirect that lands back on the origin after the flow completed
817
+ # elsewhere -- or after the TTL -- is not replayed.
818
+ await self._reconcile_flow_from_durable(state_token=state)
819
+
820
+ async with self._store.lock:
821
+ flow = self._store.get_flow_by_state_token_locked(state)
822
+ verifier = flow.code_verifier if flow is not None else None
823
+ terminal_status = flow.status if (flow is not None and flow.status in _TERMINAL_OAUTH_STATUSES) else None
824
+ terminal_error = flow.error_message if flow is not None else None
825
+
826
+ # Durable terminal wins: do not replay a consumed authorization code.
827
+ if terminal_status is not None:
828
+ if terminal_status == "success":
829
+ return self._html_response(_success_html())
830
+ return self._html_response(_error_html(terminal_error or "Authorization failed."))
831
+
832
+ if error:
833
+ outcome = await self._finalize_callback_error(
834
+ f"OAuth error: {error}", flow_id=flow.flow_id if flow is not None else None
835
+ )
836
+ if outcome == "success":
837
+ return self._html_response(_success_html())
838
+ return self._html_response(_error_html("Authorization failed."))
839
+
840
+ if not code or not state or flow is None or not verifier:
841
+ outcome = await self._finalize_callback_error(
842
+ "Invalid OAuth callback state.", flow_id=flow.flow_id if flow is not None else None
843
+ )
844
+ if outcome == "success":
845
+ return self._html_response(_success_html())
846
+ return self._html_response(_error_html("Invalid OAuth callback."))
847
+
848
+ try:
849
+ route = await _oauth_route()
850
+ tokens = await exchange_authorization_code(
851
+ code=code,
852
+ code_verifier=verifier,
853
+ route=route,
854
+ allow_direct_egress=route is None,
855
+ )
856
+ await self._persist_tokens(tokens, intended_account_id=flow.intended_account_id)
857
+ await self._set_success(flow.flow_id)
858
+ html = _success_html()
859
+ except OAuthError as exc:
860
+ # Loser race: honor a durable success committed by a concurrent
861
+ # callback for the same single-use code instead of showing an error.
862
+ html = (
863
+ _success_html()
864
+ if await self._finalize_callback_error(exc.message, flow_id=flow.flow_id) == "success"
865
+ else _error_html(exc.message)
866
+ )
867
+ except ReauthSeatMismatchError:
868
+ html = (
869
+ _success_html()
870
+ if await self._finalize_callback_error(_REAUTH_SEAT_MISMATCH_MESSAGE, flow_id=flow.flow_id) == "success"
871
+ else _error_html(_REAUTH_SEAT_MISMATCH_MESSAGE)
872
+ )
873
+ except AccountIdentityConflictError:
874
+ html = (
875
+ _success_html()
876
+ if await self._finalize_callback_error(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE, flow_id=flow.flow_id)
877
+ == "success"
878
+ else _error_html(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE)
879
+ )
880
+
881
+ asyncio.create_task(self._stop_callback_server_if_idle())
882
+ return self._html_response(html)
883
+
884
+ async def _poll_device_tokens(self, flow_id: str | None, context: "DevicePollContext") -> None:
885
+ # Slot ownership is the single authority for who may complete a device
886
+ # flow. Only the poller that atomically consumed the current device slot
887
+ # may persist an account OR write ANY terminal status (success or error).
888
+ # A poller that does not hold/win the slot writes NOTHING: this prevents a
889
+ # losing/duplicate poller that received ``invalid_grant`` for the consumed
890
+ # code from writing ``error`` during the winner's persist window (which
891
+ # would stop the dashboard polling before the winner's success lands).
892
+ consumed = False
893
+ try:
894
+ while time.time() < context.expires_at:
895
+ route = await _oauth_route()
896
+ tokens = await exchange_device_token(
897
+ device_auth_id=context.device_auth_id,
898
+ user_code=context.user_code,
899
+ route=route,
900
+ allow_direct_egress=route is None,
901
+ )
902
+ if tokens:
903
+ # Point of no return: consume the single-active slot. If a
904
+ # newer start superseded this flow, the consume matches zero
905
+ # rows and we abort WITHOUT persisting or writing anything.
906
+ consumed = await self._consume_device_slot(flow_id)
907
+ if not consumed:
908
+ return
909
+ await self._persist_tokens(tokens, intended_account_id=context.intended_account_id)
910
+ await self._set_success(flow_id)
911
+ return
912
+ await _async_sleep(context.interval_seconds)
913
+ # Code expired: only the slot holder may record the terminal error.
914
+ if consumed or await self._consume_device_slot(flow_id):
915
+ await self._set_error("Device code expired.", flow_id=flow_id)
916
+ except OAuthError as exc:
917
+ if consumed or await self._consume_device_slot(flow_id):
918
+ await self._set_error(exc.message, flow_id=flow_id)
919
+ except ReauthSeatMismatchError:
920
+ if consumed or await self._consume_device_slot(flow_id):
921
+ await self._set_error(_REAUTH_SEAT_MISMATCH_MESSAGE, flow_id=flow_id)
922
+ except AccountIdentityConflictError:
923
+ if consumed or await self._consume_device_slot(flow_id):
924
+ await self._set_error(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE, flow_id=flow_id)
925
+ finally:
926
+ async with self._store.lock:
927
+ flow = self._store.get_flow_locked(flow_id)
928
+ current = asyncio.current_task()
929
+ if flow is not None and flow.poll_task is current:
930
+ flow.poll_task = None
931
+ self._store.set_latest_flow_locked(flow)
932
+
933
+ def _ensure_device_poll_task_locked(self, state: OAuthState) -> bool:
934
+ if state.poll_task and not state.poll_task.done():
935
+ return True
936
+ if not state.device_auth_id or not state.user_code or not state.expires_at:
937
+ return False
938
+
939
+ interval = state.interval_seconds if state.interval_seconds is not None else 0
940
+ poll_context = DevicePollContext(
941
+ device_auth_id=state.device_auth_id,
942
+ user_code=state.user_code,
943
+ interval_seconds=max(interval, 0),
944
+ expires_at=state.expires_at,
945
+ intended_account_id=state.intended_account_id,
946
+ )
947
+ state.poll_task = asyncio.create_task(self._poll_device_tokens(state.flow_id, poll_context))
948
+ return True
949
+
950
+ async def _persist_tokens(
951
+ self,
952
+ tokens: OAuthTokens,
953
+ *,
954
+ intended_account_id: str | None = None,
955
+ ) -> None:
956
+ claims = extract_id_token_claims(tokens.id_token)
957
+ auth_claims = claims.auth or OpenAIAuthClaims()
958
+ raw_account_id = auth_claims.chatgpt_account_id or claims.chatgpt_account_id
959
+ chatgpt_user_id = resolve_seat_identity(claims, auth_claims)
960
+ email = claims.email or DEFAULT_EMAIL
961
+ workspace_id = clean_account_identity_part(auth_claims.workspace_id or claims.workspace_id)
962
+ workspace_label = clean_account_identity_part(auth_claims.workspace_label or claims.workspace_label)
963
+ seat_type = normalize_seat_type(auth_claims.seat_type or claims.seat_type)
964
+ account_id = generate_unique_account_id(raw_account_id, email, workspace_id, workspace_label)
965
+ plan_type = coerce_account_plan_type(
966
+ auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
967
+ DEFAULT_PLAN,
968
+ )
969
+
970
+ account = Account(
971
+ id=intended_account_id or account_id,
972
+ chatgpt_account_id=raw_account_id,
973
+ chatgpt_user_id=chatgpt_user_id,
974
+ email=email,
975
+ workspace_id=workspace_id,
976
+ workspace_label=workspace_label,
977
+ seat_type=seat_type,
978
+ plan_type=plan_type,
979
+ access_token_encrypted=self._encryptor.encrypt(tokens.access_token),
980
+ refresh_token_encrypted=self._encryptor.encrypt(tokens.refresh_token),
981
+ id_token_encrypted=self._encryptor.encrypt(tokens.id_token),
982
+ last_refresh=utcnow(),
983
+ status=AccountStatus.ACTIVE,
984
+ deactivation_reason=None,
985
+ )
986
+ if self._repo_factory:
987
+ async with self._repo_factory() as repo:
988
+ saved = await self._save_oauth_account(repo, account, intended_account_id=intended_account_id)
989
+ saved_id = saved.id
990
+ else:
991
+ saved = await self._save_oauth_account(
992
+ self._accounts_repo,
993
+ account,
994
+ intended_account_id=intended_account_id,
995
+ )
996
+ saved_id = saved.id
997
+
998
+ clear_account_routing_unavailable(saved_id)
999
+ await self._invalidate_account_routing_caches()
1000
+
1001
+ async def _save_oauth_account(
1002
+ self,
1003
+ repo: AccountsRepository,
1004
+ account: Account,
1005
+ *,
1006
+ intended_account_id: str | None,
1007
+ ) -> Account:
1008
+ if intended_account_id is None:
1009
+ return await repo.upsert_account_slot(
1010
+ account,
1011
+ preserve_unknown_workspace_duplicates=False,
1012
+ preserve_identity_slots=True,
1013
+ )
1014
+
1015
+ intended = await repo.get_by_id(intended_account_id)
1016
+ if intended is None:
1017
+ raise ReauthSeatMismatchError(None, account.email)
1018
+
1019
+ intended_user_id = intended.chatgpt_user_id
1020
+ intended_claims = None
1021
+ if intended_user_id is None:
1022
+ try:
1023
+ intended_token = self._encryptor.decrypt(intended.id_token_encrypted)
1024
+ intended_claims = extract_id_token_claims(intended_token)
1025
+ intended_user_id = resolve_seat_identity(intended_claims, intended_claims.auth)
1026
+ except Exception:
1027
+ intended_user_id = None
1028
+ if intended_claims is None:
1029
+ try:
1030
+ intended_claims = extract_id_token_claims(self._encryptor.decrypt(intended.id_token_encrypted))
1031
+ except Exception:
1032
+ intended_claims = None
1033
+
1034
+ intended_workspace = clean_account_identity_part(intended.workspace_id or intended.workspace_label)
1035
+ callback_workspace = clean_account_identity_part(account.workspace_id or account.workspace_label)
1036
+ callback_claims = None
1037
+ try:
1038
+ callback_claims = extract_id_token_claims(self._encryptor.decrypt(account.id_token_encrypted))
1039
+ except Exception:
1040
+ callback_claims = None
1041
+
1042
+ intended_seat_ids = {
1043
+ clean_account_identity_part(value)
1044
+ for value in (intended_user_id, intended_claims.sub if intended_claims else None)
1045
+ if clean_account_identity_part(value)
1046
+ }
1047
+ callback_seat_ids = {
1048
+ clean_account_identity_part(value)
1049
+ for value in (
1050
+ account.chatgpt_user_id,
1051
+ callback_claims.sub if callback_claims else None,
1052
+ )
1053
+ if clean_account_identity_part(value)
1054
+ }
1055
+
1056
+ workspace_matches = (
1057
+ intended.chatgpt_account_id is None or intended.chatgpt_account_id == account.chatgpt_account_id
1058
+ )
1059
+ if workspace_matches and intended.chatgpt_account_id is None and intended_workspace is not None:
1060
+ workspace_matches = bool(callback_workspace is not None and callback_workspace == intended_workspace)
1061
+ seat_matches = bool(intended_seat_ids & callback_seat_ids)
1062
+ if not workspace_matches or not seat_matches:
1063
+ raise ReauthSeatMismatchError(intended.email, account.email)
1064
+
1065
+ saved = await repo.replace_reauthorized(intended_account_id, account)
1066
+ if saved is None:
1067
+ raise ReauthSeatMismatchError(intended.email, account.email)
1068
+ return saved
1069
+
1070
+ async def _invalidate_account_routing_caches(self) -> None:
1071
+ get_account_selection_cache().invalidate()
1072
+ get_api_key_cache().clear()
1073
+ await propagate_account_routing_change()
1074
+ poller = get_cache_invalidation_poller()
1075
+ if poller is not None:
1076
+ await poller.bump(NAMESPACE_API_KEY)
1077
+
1078
+ async def _set_success(self, flow_id: str | None = None) -> None:
1079
+ async with self._store.lock:
1080
+ flow = self._store.get_flow_locked(flow_id)
1081
+ if flow is not None:
1082
+ self._store.set_flow_status_locked(flow, status="success", error_message=None)
1083
+ elif flow_id is None:
1084
+ self._store.state.status = "success"
1085
+ self._store.state.error_message = None
1086
+ if flow_id is not None:
1087
+ # Durable, cross-replica status: the originating replica reads this
1088
+ # on its next status poll instead of its stale in-memory pending.
1089
+ await self._persist_flow_status(flow_id, status="success", error_message=None)
1090
+
1091
+ async def _set_error(self, message: str, flow_id: str | None = None) -> bool:
1092
+ """Record a terminal error. Returns whether the durable error was applied.
1093
+
1094
+ The durable write happens FIRST: if the monotonic guard rejects it
1095
+ (``False``) because the durable row is already ``success`` — a racing
1096
+ callback/poller committed success for the same single-use code — the
1097
+ local in-memory flow is left untouched (never shadowed into ``error``),
1098
+ and the caller must honor the durable success instead of surfacing the
1099
+ error. ``flow_id`` of ``None`` updates only the local latest state.
1100
+ """
1101
+
1102
+ if flow_id is None:
1103
+ async with self._store.lock:
1104
+ if self._store.state.flow_id is None:
1105
+ self._store.state.status = "error"
1106
+ self._store.state.error_message = message
1107
+ return True
1108
+ applied = await self._persist_flow_status(flow_id, status="error", error_message=message)
1109
+ if not applied:
1110
+ return False
1111
+ async with self._store.lock:
1112
+ flow = self._store.get_flow_locked(flow_id)
1113
+ if flow is not None:
1114
+ self._store.set_flow_status_locked(flow, status="error", error_message=message)
1115
+ return True
1116
+
1117
+ async def _finalize_callback_error(self, message: str, *, flow_id: str | None) -> str:
1118
+ """Record a terminal error for a browser/manual-callback flow and return
1119
+ the status to report. If the durable error write is rejected because a
1120
+ racing callback already committed ``success`` for the same single-use
1121
+ code, reconcile the local flow and report the durable ``success`` instead
1122
+ of misreporting an error (the loser must not surface an error)."""
1123
+
1124
+ if await self._set_error(message, flow_id=flow_id):
1125
+ return "error"
1126
+ record = await self._reconcile_flow_from_durable(flow_id=flow_id) if flow_id is not None else None
1127
+ if record is not None and record.status == "success":
1128
+ return "success"
1129
+ return "error"
1130
+
1131
+ def _start_callback_server_stop_locked(self, server: OAuthCallbackServer) -> asyncio.Task[None]:
1132
+ stop_task = self._store._callback_server_stop_task
1133
+ if stop_task is not None and not stop_task.done():
1134
+ return stop_task
1135
+ stop_task = asyncio.create_task(server.stop())
1136
+ self._store._callback_server_stop_task = stop_task
1137
+ return stop_task
1138
+
1139
+ async def _finish_callback_server_stop(
1140
+ self,
1141
+ server: OAuthCallbackServer,
1142
+ stop_task: asyncio.Task[None],
1143
+ ) -> None:
1144
+ try:
1145
+ await asyncio.shield(stop_task)
1146
+ finally:
1147
+ async with self._store.lock:
1148
+ if self._store._callback_server is server:
1149
+ self._store._callback_server = None
1150
+ if self._store._callback_server_stop_task is stop_task:
1151
+ self._store._callback_server_stop_task = None
1152
+
1153
+ async def _wait_for_callback_server_stop(self) -> None:
1154
+ while True:
1155
+ async with self._store.lock:
1156
+ stop_task = self._store._callback_server_stop_task
1157
+ if stop_task is None:
1158
+ return
1159
+ await asyncio.shield(stop_task)
1160
+
1161
+ async def _stop_callback_server_if_idle(self) -> None:
1162
+ server: OAuthCallbackServer | None = None
1163
+ stop_task: asyncio.Task[None] | None = None
1164
+ async with self._store.lock:
1165
+ if self._store.has_pending_browser_flows_locked():
1166
+ return
1167
+ server = self._store._callback_server
1168
+ if server:
1169
+ stop_task = self._start_callback_server_stop_locked(server)
1170
+ if server and stop_task:
1171
+ await self._finish_callback_server_stop(server, stop_task)
1172
+
1173
+ @staticmethod
1174
+ def _html_response(html: str) -> web.Response:
1175
+ return web.Response(text=html, content_type="text/html")
1176
+
1177
+
1178
+ @dataclass(frozen=True)
1179
+ class DevicePollContext:
1180
+ device_auth_id: str
1181
+ user_code: str
1182
+ interval_seconds: int
1183
+ expires_at: float
1184
+ intended_account_id: str | None = None
1185
+
1186
+
1187
+ def _success_html() -> str:
1188
+ try:
1189
+ return _SUCCESS_TEMPLATE.read_text(encoding="utf-8")
1190
+ except OSError:
1191
+ return "<html><body><h1>Login complete</h1><p>Return to the dashboard.</p></body></html>"
1192
+
1193
+
1194
+ def _error_html(message: str) -> str:
1195
+ escaped_message = html.escape(message, quote=True)
1196
+ return f"<html><body><h1>Login failed</h1><p>{escaped_message}</p></body></html>"