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,1439 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextvars
5
+ import inspect
6
+ import logging
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Coroutine, Mapping
9
+ from contextlib import AbstractAsyncContextManager
10
+ from datetime import datetime
11
+ from hashlib import sha256
12
+ from typing import Any, Protocol, TypeAlias
13
+
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 DEFAULT_PLAN, OpenAIAuthClaims, extract_id_token_claims
19
+ from app.core.auth.refresh import (
20
+ TOKEN_REFRESH_TIMEOUT_SECONDS,
21
+ RefreshError,
22
+ TokenRefreshResult,
23
+ get_token_refresh_timeout_override,
24
+ pop_token_refresh_timeout_override,
25
+ push_token_refresh_timeout_override,
26
+ refresh_access_token,
27
+ should_refresh,
28
+ )
29
+ from app.core.balancer import PERMANENT_FAILURE_CODES, account_status_for_permanent_failure
30
+ from app.core.crypto import TokenEncryptor
31
+ from app.core.plan_types import coerce_account_plan_type
32
+ from app.core.upstream_proxy import UpstreamProxyRouteError, resolve_upstream_route
33
+ from app.core.utils.shared_future import wait_on_shared_future
34
+ from app.core.utils.time import utcnow
35
+ from app.db.models import Account, AccountProxyBinding, AccountStatus
36
+ from app.db.session import get_background_session
37
+ from app.modules.accounts.refresh_claims import RefreshClaimCoordinatorPort, get_refresh_claim_coordinator
38
+ from app.modules.proxy.account_cache import get_account_selection_cache, mark_account_routing_unavailable
39
+ from app.modules.proxy.work_admission import ADMISSION_WAIT_TIMEOUT_SECONDS
40
+
41
+
42
+ class AccountsRepositoryPort(Protocol):
43
+ async def get_by_id(self, account_id: str) -> Account | None: ...
44
+
45
+ async def get_by_id_fresh(self, account_id: str) -> Account | None: ...
46
+
47
+ async def update_status(
48
+ self,
49
+ account_id: str,
50
+ status: AccountStatus,
51
+ deactivation_reason: str | None = None,
52
+ reset_at: int | None = None,
53
+ blocked_at: int | None = None,
54
+ ) -> bool: ...
55
+
56
+ async def update_status_if_current(
57
+ self,
58
+ account_id: str,
59
+ status: AccountStatus,
60
+ deactivation_reason: str | None = None,
61
+ reset_at: int | None = None,
62
+ *,
63
+ expected_status: AccountStatus,
64
+ expected_deactivation_reason: str | None = None,
65
+ expected_reset_at: int | None = None,
66
+ expected_refresh_token_encrypted: bytes | None = None,
67
+ ) -> bool: ...
68
+
69
+ async def rotate_tokens(
70
+ self,
71
+ account_id: str,
72
+ access_token_encrypted: bytes,
73
+ refresh_token_encrypted: bytes,
74
+ id_token_encrypted: bytes,
75
+ last_refresh: datetime,
76
+ *,
77
+ expected_refresh_token_encrypted: bytes,
78
+ plan_type: str | None = None,
79
+ email: str | None = None,
80
+ chatgpt_account_id: str | None = None,
81
+ chatgpt_user_id: str | None = None,
82
+ workspace_id: str | None = None,
83
+ workspace_label: str | None = None,
84
+ seat_type: str | None = None,
85
+ ) -> bool: ...
86
+
87
+ async def update_account_metadata(
88
+ self,
89
+ account_id: str,
90
+ *,
91
+ plan_type: str | None = None,
92
+ email: str | None = None,
93
+ chatgpt_account_id: str | None = None,
94
+ chatgpt_user_id: str | None = None,
95
+ workspace_id: str | None = None,
96
+ workspace_label: str | None = None,
97
+ seat_type: str | None = None,
98
+ last_refresh: datetime | None = None,
99
+ ) -> bool: ...
100
+
101
+ async def workspace_slot_taken(
102
+ self,
103
+ *,
104
+ account_id: str,
105
+ email: str,
106
+ chatgpt_account_id: str | None,
107
+ workspace_id: str,
108
+ ) -> bool: ...
109
+
110
+
111
+ class RefreshAdmissionLeasePort(Protocol):
112
+ def release(self) -> None: ...
113
+
114
+
115
+ logger = logging.getLogger(__name__)
116
+
117
+ # Bound on how many times a successful refresh retries its token compare-and-set
118
+ # against freshly observed ciphertext when a concurrent re-auth/import merely
119
+ # re-encrypts the same refresh-token plaintext (Fernet ciphertext is
120
+ # non-deterministic, so the same plaintext yields different bytes). Genuine
121
+ # newer rotations are adopted instead of retried, so this only guards the rare
122
+ # same-plaintext re-encryption race and must stay small to avoid live-locking.
123
+ _TOKEN_CAS_MAX_ATTEMPTS = 5
124
+
125
+ # Dedicated bounded retry budget for the FINAL safety persist of a freshly
126
+ # rotated token, DELIBERATELY SEPARATE from the claim/caller deadline. Persisting
127
+ # a valid single-use rotated token is worth a few extra milliseconds over budget:
128
+ # giving up here would strand the account holding the already-consumed token, and
129
+ # a later blind retry would re-exchange that consumed token into an
130
+ # ``invalid_grant``/reauth knockout of an otherwise healthy account. Each attempt
131
+ # is still a ciphertext-guarded compare-and-set against the freshly re-read
132
+ # ciphertext (a genuinely different peer plaintext is ADOPTED, never clobbered);
133
+ # because any ciphertext change means a writer committed and no realistic writer
134
+ # re-encrypts the SAME consumed token in a tight loop, this lands within a couple
135
+ # of attempts in practice. Kept small (with tiny backoff) so a truly pathological
136
+ # storm cannot live-lock.
137
+ _FINAL_PERSIST_MAX_ATTEMPTS = 3
138
+ _FINAL_PERSIST_RETRY_BASE_SECONDS = 0.02
139
+
140
+ # Bound on how many times releasing a refresh claim is retried when the release
141
+ # DELETE hits a transient DB error (SQLite lock past the busy timeout, a dropped
142
+ # Postgres connection). Release runs in ``finally`` after the token update has
143
+ # already committed, so a release failure must never mask a successful refresh;
144
+ # after a couple of brief retries we log and swallow and let the claim expire by
145
+ # its TTL.
146
+ _CLAIM_RELEASE_MAX_ATTEMPTS = 3
147
+ _CLAIM_RELEASE_RETRY_BASE_SECONDS = 0.05
148
+
149
+ # Cross-replica refresh-claim wait/poll tuning (fixed; issue #1340 /
150
+ # PRINCIPLES.md P2). The wait caps how long a non-claimant polls for the claim
151
+ # winner's rotated tokens before giving up; the poll interval bounds
152
+ # claim-table read pressure while waiting.
153
+ _TOKEN_REFRESH_CLAIM_WAIT_SECONDS = 8.0
154
+ _TOKEN_REFRESH_CLAIM_POLL_SECONDS = 0.25
155
+ # Cross-replica token-refresh claim TTL (account_refresh_claims table). The
156
+ # claim is acquired BEFORE the refresh-admission wait and held through the
157
+ # OAuth exchange, so the TTL must cover both: the admission wait ceiling plus
158
+ # the HTTP exchange (2x for margin). A TTL sized only around the HTTP timeout
159
+ # could expire under a healthy claimant stuck in admission, letting another
160
+ # replica claim the same account and reuse the single-use refresh token. The
161
+ # 30 s floor bounds how long a crashed claimant can block refresh for one
162
+ # account.
163
+ _TOKEN_REFRESH_CLAIM_TTL_FLOOR_SECONDS = 30.0
164
+ # Negative cache for a failed refresh so a burst of requests for one account
165
+ # does not re-run the same failing exchange (transport errors are exempt).
166
+ _REFRESH_FAILURE_COOLDOWN_SECONDS = 5.0
167
+
168
+
169
+ def _token_refresh_claim_ttl_seconds() -> float:
170
+ return max(
171
+ _TOKEN_REFRESH_CLAIM_TTL_FLOOR_SECONDS,
172
+ ADMISSION_WAIT_TIMEOUT_SECONDS + 2.0 * TOKEN_REFRESH_TIMEOUT_SECONDS,
173
+ )
174
+
175
+
176
+ # Terminal account statuses a PRIOR claim holder may have committed while
177
+ # leaving ``refresh_token_encrypted`` UNCHANGED: a permanent refresh failure
178
+ # (e.g. ``invalid_grant``) downgraded through ``_handle_permanent_refresh_failure``
179
+ # or the safe-terminal persist-conflict path (``_flag_persist_conflict_reauth``),
180
+ # both of which flag REAUTH_REQUIRED/DEACTIVATED without rotating the token. A
181
+ # waiter that wins the RELEASED claim and re-reads one of these on the SAME
182
+ # (unchanged) refresh material must NOT re-exchange the stored consumed/dead
183
+ # token; it must fail closed and surface the terminal state instead.
184
+ _TERMINAL_REFRESH_STATUSES = frozenset({AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED})
185
+
186
+
187
+ _RefreshSingleflightKey: TypeAlias = tuple[str, str]
188
+
189
+
190
+ class _RefreshSingleflight:
191
+ def __init__(self) -> None:
192
+ self._inflight: dict[_RefreshSingleflightKey, asyncio.Task[Account]] = {}
193
+ self._recent_failures: dict[_RefreshSingleflightKey, tuple[float, tuple[str, str, bool]]] = {}
194
+ self._lock = asyncio.Lock()
195
+
196
+ async def run(
197
+ self,
198
+ key: _RefreshSingleflightKey,
199
+ factory: Callable[[], Coroutine[object, object, Account]],
200
+ ) -> Account:
201
+ account_id = key[0]
202
+ async with self._lock:
203
+ self._purge_stale_versions(account_id, keep_key=key)
204
+ cached_failure = self._recent_failures.get(key)
205
+ if cached_failure is not None:
206
+ expires_at, failure = cached_failure
207
+ if expires_at > time.monotonic():
208
+ code, message, is_permanent = failure
209
+ raise RefreshError(code, message, is_permanent)
210
+ self._recent_failures.pop(key, None)
211
+ task = self._inflight.get(key)
212
+ if task is not None and task.done() and not task.cancelled() and task.exception() is None:
213
+ pass
214
+ elif task is None or task.done():
215
+ task = asyncio.create_task(factory())
216
+ self._inflight[key] = task
217
+ task.add_done_callback(lambda done, *, cache_key=key: self._schedule_complete(cache_key, done))
218
+ assert task is not None
219
+ # Not asyncio.shield: shield attaches per-waiter callbacks to the
220
+ # shared singleflight task, which degrades to O(N^2) removal scans
221
+ # when piled-up waiters are cancelled (see shared_future.py). The
222
+ # helper preserves shield semantics: a cancelled waiter detaches
223
+ # without aborting the refresh.
224
+ return await wait_on_shared_future(task)
225
+
226
+ def _schedule_complete(self, key: _RefreshSingleflightKey, task: asyncio.Task[Account]) -> None:
227
+ asyncio.create_task(self._complete(key, task))
228
+
229
+ async def _complete(self, key: _RefreshSingleflightKey, task: asyncio.Task[Account]) -> None:
230
+ try:
231
+ async with self._lock:
232
+ current = self._inflight.get(key)
233
+ if current is not task:
234
+ # A successor owns settlement for this key; consume the
235
+ # stale task's result without touching its cache state.
236
+ if not task.cancelled():
237
+ task.exception()
238
+ return
239
+ self._inflight.pop(key, None)
240
+ if task.cancelled():
241
+ self._recent_failures.pop(key, None)
242
+ return
243
+ try:
244
+ task.result()
245
+ except RefreshError as exc:
246
+ ttl = _REFRESH_FAILURE_COOLDOWN_SECONDS
247
+ if ttl > 0 and not exc.transport_error:
248
+ self._recent_failures[key] = (
249
+ time.monotonic() + ttl,
250
+ (exc.code, exc.message, exc.is_permanent),
251
+ )
252
+ else:
253
+ self._recent_failures.pop(key, None)
254
+ except BaseException:
255
+ self._recent_failures.pop(key, None)
256
+ else:
257
+ self._recent_failures.pop(key, None)
258
+ except BaseException:
259
+ # A singleflight task may serve both ordinary and private requests.
260
+ # Its completion callback therefore has no request-local privacy
261
+ # policy and must keep diagnostics content-free.
262
+ logger.error("Refresh singleflight completion cleanup failed")
263
+
264
+ def _purge_stale_versions(self, account_id: str, *, keep_key: _RefreshSingleflightKey) -> None:
265
+ stale_failures = [key for key in self._recent_failures if key[0] == account_id and key != keep_key]
266
+ for key in stale_failures:
267
+ self._recent_failures.pop(key, None)
268
+ stale_inflight = [
269
+ key for key, task in self._inflight.items() if key[0] == account_id and key != keep_key and task.done()
270
+ ]
271
+ for key in stale_inflight:
272
+ self._inflight.pop(key, None)
273
+
274
+ def clear(self) -> None:
275
+ self._inflight.clear()
276
+ self._recent_failures.clear()
277
+
278
+
279
+ _REFRESH_SINGLEFLIGHT = _RefreshSingleflight()
280
+
281
+
282
+ class AuthManager:
283
+ def __init__(
284
+ self,
285
+ repo: AccountsRepositoryPort,
286
+ *,
287
+ acquire_refresh_admission: Callable[[], Awaitable[RefreshAdmissionLeasePort]] | None = None,
288
+ refresh_repo_factory: Callable[[], AbstractAsyncContextManager[AccountsRepositoryPort]] | None = None,
289
+ refresh_claims: RefreshClaimCoordinatorPort | None = None,
290
+ redact_sensitive_details: bool = False,
291
+ ) -> None:
292
+ self._repo = repo
293
+ self._encryptor = TokenEncryptor()
294
+ self._acquire_refresh_admission = acquire_refresh_admission
295
+ self._redact_sensitive_details = redact_sensitive_details
296
+ # Optional factory yielding a *fresh* accounts repo (own DB session) for
297
+ # the detached, shielded refresh task. When set, the singleflight body
298
+ # runs against this session instead of the request-scoped `repo`, so a
299
+ # caller cancelled by a client disconnect cannot close the session out
300
+ # from under the still-running refresh task and strand a pooled
301
+ # connection. See _run_refresh.
302
+ self._refresh_repo_factory = refresh_repo_factory
303
+ # Cross-replica refresh claim coordinator. ``None`` defers to the
304
+ # process default (see refresh_claims.get_refresh_claim_coordinator),
305
+ # which the test harness may set to ``None`` to disable claims.
306
+ self._refresh_claims = refresh_claims
307
+
308
+ async def ensure_fresh(self, account: Account, *, force: bool = False) -> Account:
309
+ if force or (account.status != AccountStatus.REAUTH_REQUIRED and should_refresh(account.last_refresh)):
310
+ account = await _REFRESH_SINGLEFLIGHT.run(
311
+ _refresh_singleflight_key(self._encryptor, account),
312
+ lambda: self._run_refresh(account),
313
+ )
314
+ return await self._ensure_chatgpt_account_id(account)
315
+
316
+ async def _run_refresh(self, account: Account) -> Account:
317
+ """Singleflight body for token refresh.
318
+
319
+ Runs inside a detached task that the singleflight keeps alive via
320
+ ``wait_on_shared_future`` (so concurrent waiters share one refresh and
321
+ a cancelled waiter does not abort it). Because the task outlives the
322
+ caller, it MUST NOT use the caller's request-scoped session: when a
323
+ client disconnects, the caller is cancelled and its
324
+ ``async with get_background_session()`` closes that session, while this
325
+ shielded task keeps running and would then touch a closed,
326
+ concurrently-finalized ``AsyncSession`` (not safe for concurrent use) —
327
+ stranding a pooled connection that never returns. When a
328
+ ``refresh_repo_factory`` is provided, open a fresh session here so the
329
+ refresh write is fully self-contained; otherwise fall back to the bound
330
+ repo (callers whose session is not client-cancellable, e.g. the usage
331
+ refresh scheduler).
332
+ """
333
+ # The process-global singleflight can be joined by an ordinary request
334
+ # and a private realtime request in either order. Task-internal logging
335
+ # cannot safely inherit the first caller's policy, so shared refresh
336
+ # work always uses the strict, content-free diagnostic mode. Per-caller
337
+ # work after the shared task (for example the account-id metadata
338
+ # backfill) retains the caller's explicit policy.
339
+ if self._refresh_repo_factory is None:
340
+ owned = AuthManager(
341
+ self._repo,
342
+ acquire_refresh_admission=self._acquire_refresh_admission,
343
+ refresh_claims=self._refresh_claims,
344
+ redact_sensitive_details=True,
345
+ )
346
+ return await owned.refresh_account(account)
347
+ async with self._refresh_repo_factory() as repo:
348
+ owned = AuthManager(
349
+ repo,
350
+ acquire_refresh_admission=self._acquire_refresh_admission,
351
+ refresh_claims=self._refresh_claims,
352
+ redact_sensitive_details=True,
353
+ )
354
+ return await owned.refresh_account(account)
355
+
356
+ def _diagnostic_value(self, value: str | None) -> str | None:
357
+ if self._redact_sensitive_details and value is not None:
358
+ return "<redacted>"
359
+ return value
360
+
361
+ async def refresh_account(self, account: Account) -> Account:
362
+ claims = self._refresh_claims if self._refresh_claims is not None else get_refresh_claim_coordinator()
363
+ if claims is None:
364
+ requested_fingerprint = _refresh_token_material_fingerprint(
365
+ self._encryptor,
366
+ account.refresh_token_encrypted,
367
+ )
368
+ latest = await self._repo.get_by_id_fresh(account.id)
369
+ if latest is not None:
370
+ if (
371
+ _refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
372
+ != requested_fingerprint
373
+ ):
374
+ return _adopt_account_row(account, latest)
375
+ _adopt_account_row(account, latest)
376
+ if account.status in _TERMINAL_REFRESH_STATUSES:
377
+ raise _terminal_status_refresh_error(account)
378
+ return await self._perform_refresh(account, refresh_token_encrypted=account.refresh_token_encrypted)
379
+ return await self._refresh_account_with_claim(account, claims)
380
+
381
+ async def _refresh_account_with_claim(
382
+ self,
383
+ account: Account,
384
+ claims: RefreshClaimCoordinatorPort,
385
+ ) -> Account:
386
+ """Serialize the upstream token exchange across replicas.
387
+
388
+ Exactly one claimant per account may run the OAuth exchange at a time;
389
+ everyone else waits (bounded) for the winner's rotated tokens to land
390
+ and adopts them without an upstream call. Refresh tokens are single-use
391
+ upstream, so a second concurrent exchange would receive a permanent
392
+ ``refresh_token_reused`` error and could revoke the token family.
393
+ """
394
+ requested_fingerprint = _refresh_token_material_fingerprint(
395
+ self._encryptor,
396
+ account.refresh_token_encrypted,
397
+ )
398
+ # The wait for a foreign claim is bounded by the fixed cap AND the
399
+ # caller's remaining refresh budget: the singleflight body is shielded
400
+ # and outlives a cancelled caller, so without the budget cap a small
401
+ # request budget with a held foreign claim would leave this task
402
+ # polling for the full fixed wait (holding its repo session and
403
+ # the inflight singleflight entry that later callers join).
404
+ wait_seconds = _TOKEN_REFRESH_CLAIM_WAIT_SECONDS
405
+ caller_budget = get_token_refresh_timeout_override()
406
+ start = time.monotonic()
407
+ # Absolute deadline of the caller's ORIGINAL refresh budget (if any).
408
+ # Used to cap the post-wait upstream exchange so a long claim wait
409
+ # cannot be followed by a full-budget OAuth exchange that overruns the
410
+ # request deadline.
411
+ caller_deadline: float | None = None
412
+ if caller_budget is not None:
413
+ caller_budget = max(0.0, caller_budget)
414
+ caller_deadline = start + caller_budget
415
+ wait_seconds = min(wait_seconds, caller_budget)
416
+ deadline = start + wait_seconds
417
+ poll_seconds = _TOKEN_REFRESH_CLAIM_POLL_SECONDS
418
+ # NOTE: comparisons below use the fingerprint captured at entry, not
419
+ # ``account.refresh_token_encrypted``: when ``account`` is attached to
420
+ # the repo's session, ``get_by_id_fresh`` refreshes that very
421
+ # identity-map object in place, so comparing against the live attribute
422
+ # would compare the row with itself.
423
+ while True:
424
+ if await claims.try_acquire(
425
+ account.id,
426
+ ttl_seconds=_token_refresh_claim_ttl_seconds(),
427
+ owner=requested_fingerprint,
428
+ ):
429
+ # Monotonic deadline covering the ENTIRE claim hold from this
430
+ # point: the exchange AND the post-exchange DB persist/status-CAS
431
+ # loops all run while this claim is held. The exchange itself is
432
+ # already budget-bounded, but the persist/status write loops were
433
+ # bounded only by attempt count -- a contended DB write could keep
434
+ # them looping past the claim TTL, after which a peer can win the
435
+ # claim and re-exchange the (now consumed) single-use token. Bound
436
+ # the persist section by the smaller of the claim TTL and the
437
+ # caller's remaining budget so total claim-hold stays within
438
+ # budget + a small fixed release, and a persist that runs past the
439
+ # deadline stops (releasing the claim) instead of looping.
440
+ claim_ttl = _token_refresh_claim_ttl_seconds()
441
+ persist_deadline = time.monotonic() + claim_ttl
442
+ if caller_deadline is not None:
443
+ persist_deadline = min(persist_deadline, caller_deadline)
444
+ try:
445
+ # Post-claim fresh re-read: another replica may have rotated
446
+ # the material between the caller's read and our claim.
447
+ latest = await self._repo.get_by_id_fresh(account.id)
448
+ if latest is not None:
449
+ if (
450
+ _refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
451
+ != requested_fingerprint
452
+ ):
453
+ # A peer genuinely rotated/repaired the material; adopt
454
+ # it and proceed without an upstream call.
455
+ return _adopt_account_row(account, latest)
456
+ if latest.status in _TERMINAL_REFRESH_STATUSES:
457
+ # A PRIOR claim holder finished by committing a terminal
458
+ # status (a permanent ``invalid_grant`` downgrade, or the
459
+ # safe-terminal persist-conflict path) WITHOUT rotating
460
+ # ``refresh_token_encrypted``. The fresh row therefore
461
+ # still holds the SAME consumed/dead token: re-exchanging
462
+ # it would just repeat a permanent refresh failure and
463
+ # defeat the fail-closed behavior the terminal flag exists
464
+ # to enforce. Adopt the terminal row (so the caller's
465
+ # object mirrors the committed state) and surface it as a
466
+ # PERMANENT failure instead of starting another upstream
467
+ # exchange. A GENUINE peer rotation is handled by the
468
+ # fingerprint branch above, so a repaired account never
469
+ # reaches here.
470
+ _adopt_account_row(account, latest)
471
+ raise _terminal_status_refresh_error(account)
472
+ fresh_material = (
473
+ latest.refresh_token_encrypted if latest is not None else account.refresh_token_encrypted
474
+ )
475
+ # The claim wait may have consumed most/all of the caller's
476
+ # refresh budget. This singleflight body is shielded from
477
+ # caller cancellation, so proceeding with the ORIGINAL
478
+ # ``token_refresh_timeout_override`` still active would let the
479
+ # OAuth exchange spend a whole budget AGAIN — overrunning the
480
+ # request deadline while pinning this repo session and the
481
+ # inflight singleflight entry that later callers join.
482
+ # Recompute the remaining budget: fail fast with the transient
483
+ # claim timeout when nothing is left, otherwise cap the
484
+ # exchange to what remains of the caller's deadline.
485
+ if caller_deadline is not None:
486
+ remaining_budget = caller_deadline - time.monotonic()
487
+ if remaining_budget <= 0:
488
+ raise RefreshError(
489
+ "refresh_claim_timeout",
490
+ f"Token refresh for account {account.id} exhausted its "
491
+ f"{caller_budget:.3f}s budget waiting for a peer replica's refresh "
492
+ f"claim before the upstream exchange could start",
493
+ False,
494
+ transport_error=True,
495
+ )
496
+ override_token = push_token_refresh_timeout_override(remaining_budget)
497
+ try:
498
+ return await self._perform_refresh(
499
+ account,
500
+ refresh_token_encrypted=fresh_material,
501
+ deadline=persist_deadline,
502
+ )
503
+ finally:
504
+ pop_token_refresh_timeout_override(override_token)
505
+ return await self._perform_refresh(
506
+ account,
507
+ refresh_token_encrypted=fresh_material,
508
+ deadline=persist_deadline,
509
+ )
510
+ finally:
511
+ await self._release_claim_quietly(claims, account.id, owner=requested_fingerprint)
512
+ # Claim held by another replica: adopt its rotation as soon as it
513
+ # commits; never write account status from the losing side.
514
+ latest = await self._repo.get_by_id_fresh(account.id)
515
+ if latest is not None and (
516
+ _refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
517
+ != requested_fingerprint
518
+ ):
519
+ return _adopt_account_row(account, latest)
520
+ remaining = deadline - time.monotonic()
521
+ if remaining <= 0:
522
+ raise RefreshError(
523
+ "refresh_claim_timeout",
524
+ f"Token refresh for account {account.id} is claimed by another replica; "
525
+ f"timed out waiting {wait_seconds:.3f}s for its rotation",
526
+ False,
527
+ transport_error=True,
528
+ )
529
+ # Cap the per-iteration sleep to the remaining claim-wait budget.
530
+ # The configured poll interval may exceed what is left of the
531
+ # caller's deadline; sleeping the full interval would let this
532
+ # shielded task overrun the caller budget while still holding its
533
+ # repo session and the inflight singleflight entry that later
534
+ # callers join. Sleep the smaller of the poll interval and the time
535
+ # remaining so the claim wait is truly bounded by the caller budget.
536
+ await asyncio.sleep(min(poll_seconds, remaining))
537
+
538
+ async def _release_claim_quietly(
539
+ self,
540
+ claims: RefreshClaimCoordinatorPort,
541
+ account_id: str,
542
+ *,
543
+ owner: str,
544
+ ) -> None:
545
+ """Release the refresh claim without ever masking the refresh result.
546
+
547
+ The release runs in ``finally`` after the token update has already
548
+ committed, so a transient DB error here (a SQLite lock past the busy
549
+ timeout, a dropped Postgres connection) MUST NOT replace a successful
550
+ ``_perform_refresh``/adoption return value with a failure and leave the
551
+ claim blocking peers until its TTL: the committed rotation is already
552
+ durable and the claim harmlessly expires on its own. So retry a couple
553
+ of times for a transient hiccup, then log and swallow.
554
+
555
+ This suppresses only the release DELETE's own errors. An exception
556
+ raised by the refresh body still propagates: when the body raised, this
557
+ coroutine runs in the ``finally``, returns normally after swallowing any
558
+ release error, and the original body exception continues to unwind.
559
+ """
560
+ last_exc: Exception | None = None
561
+ for attempt in range(_CLAIM_RELEASE_MAX_ATTEMPTS):
562
+ try:
563
+ await claims.release(account_id, owner=owner)
564
+ return
565
+ except Exception as exc: # noqa: BLE001 - release must never mask the refresh result
566
+ last_exc = exc
567
+ if attempt < _CLAIM_RELEASE_MAX_ATTEMPTS - 1:
568
+ await asyncio.sleep(_CLAIM_RELEASE_RETRY_BASE_SECONDS * (2**attempt))
569
+ logger.warning(
570
+ "Failed to release refresh claim for account_id=%s after %d attempts; leaving it to "
571
+ "expire by TTL (the committed refresh result is unaffected)",
572
+ self._diagnostic_value(account_id),
573
+ _CLAIM_RELEASE_MAX_ATTEMPTS,
574
+ exc_info=None if self._redact_sensitive_details else last_exc,
575
+ )
576
+
577
+ async def _perform_refresh(
578
+ self,
579
+ account: Account,
580
+ *,
581
+ refresh_token_encrypted: bytes,
582
+ deadline: float | None = None,
583
+ ) -> Account:
584
+ attempted_fingerprint = _refresh_token_material_fingerprint(self._encryptor, refresh_token_encrypted)
585
+ refresh_token = self._encryptor.decrypt(refresh_token_encrypted)
586
+ try:
587
+ result = await self._refresh_tokens(refresh_token, account=account)
588
+ except RefreshError as exc:
589
+ if exc.is_permanent:
590
+ adopted = await self._handle_permanent_refresh_failure(
591
+ account, exc, attempted_fingerprint, deadline=deadline
592
+ )
593
+ if adopted is not None:
594
+ return adopted
595
+ raise
596
+
597
+ new_access_token_encrypted = self._encryptor.encrypt(result.access_token)
598
+ new_refresh_token_encrypted = self._encryptor.encrypt(result.refresh_token)
599
+ new_id_token_encrypted = self._encryptor.encrypt(result.id_token)
600
+ new_last_refresh = utcnow()
601
+ new_chatgpt_account_id = result.account_id or account.chatgpt_account_id
602
+ new_chatgpt_user_id = result.chatgpt_user_id or account.chatgpt_user_id
603
+ if result.plan_type is not None:
604
+ new_plan_type = coerce_account_plan_type(
605
+ result.plan_type,
606
+ account.plan_type or DEFAULT_PLAN,
607
+ )
608
+ elif not account.plan_type:
609
+ new_plan_type = DEFAULT_PLAN
610
+ else:
611
+ new_plan_type = account.plan_type
612
+ new_email = result.email or account.email
613
+ incoming_workspace_id = _clean_optional(result.workspace_id)
614
+ current_workspace_id = _clean_optional(account.workspace_id)
615
+ next_workspace_id = current_workspace_id
616
+ if incoming_workspace_id and current_workspace_id and current_workspace_id != incoming_workspace_id:
617
+ logger.warning(
618
+ "Refresh payload reported workspace_id=%s for account_id=%s while existing "
619
+ "workspace_id=%s is already set; keeping slot identity",
620
+ self._diagnostic_value(incoming_workspace_id),
621
+ self._diagnostic_value(account.id),
622
+ self._diagnostic_value(current_workspace_id),
623
+ )
624
+ next_workspace_id = current_workspace_id
625
+ elif not current_workspace_id and incoming_workspace_id:
626
+ slot_taken = await self._repo.workspace_slot_taken(
627
+ account_id=account.id,
628
+ email=new_email,
629
+ chatgpt_account_id=new_chatgpt_account_id,
630
+ workspace_id=incoming_workspace_id,
631
+ )
632
+ if slot_taken:
633
+ logger.warning(
634
+ "Refresh payload reported workspace_id=%s for legacy account_id=%s, but that slot "
635
+ "is already owned by another account; keeping unknown workspace",
636
+ self._diagnostic_value(incoming_workspace_id),
637
+ self._diagnostic_value(account.id),
638
+ )
639
+ else:
640
+ next_workspace_id = incoming_workspace_id
641
+ workspace_matches_current_slot = incoming_workspace_id is None or incoming_workspace_id == next_workspace_id
642
+ new_workspace_label = account.workspace_label
643
+ new_seat_type = account.seat_type
644
+ if workspace_matches_current_slot and result.workspace_label:
645
+ new_workspace_label = result.workspace_label
646
+ if workspace_matches_current_slot and result.seat_type:
647
+ new_seat_type = result.seat_type
648
+
649
+ async def _write_tokens(expected_refresh_token_encrypted: bytes) -> bool:
650
+ return await self._repo.rotate_tokens(
651
+ account.id,
652
+ access_token_encrypted=new_access_token_encrypted,
653
+ refresh_token_encrypted=new_refresh_token_encrypted,
654
+ id_token_encrypted=new_id_token_encrypted,
655
+ last_refresh=new_last_refresh,
656
+ plan_type=new_plan_type,
657
+ email=new_email,
658
+ chatgpt_account_id=new_chatgpt_account_id,
659
+ chatgpt_user_id=new_chatgpt_user_id or None,
660
+ workspace_id=next_workspace_id,
661
+ workspace_label=new_workspace_label,
662
+ seat_type=new_seat_type,
663
+ expected_refresh_token_encrypted=expected_refresh_token_encrypted,
664
+ )
665
+
666
+ adopted = await self._persist_refreshed_tokens(
667
+ account,
668
+ write=_write_tokens,
669
+ expected_refresh_token_encrypted=refresh_token_encrypted,
670
+ deadline=deadline,
671
+ )
672
+ if adopted is not None:
673
+ return adopted
674
+
675
+ account.access_token_encrypted = new_access_token_encrypted
676
+ account.refresh_token_encrypted = new_refresh_token_encrypted
677
+ account.id_token_encrypted = new_id_token_encrypted
678
+ account.last_refresh = new_last_refresh
679
+ account.chatgpt_account_id = new_chatgpt_account_id
680
+ account.chatgpt_user_id = new_chatgpt_user_id
681
+ account.plan_type = new_plan_type
682
+ account.email = new_email
683
+ account.workspace_id = next_workspace_id
684
+ account.workspace_label = new_workspace_label
685
+ account.seat_type = new_seat_type
686
+ return account
687
+
688
+ async def _persist_refreshed_tokens(
689
+ self,
690
+ account: Account,
691
+ *,
692
+ write: Callable[[bytes], Awaitable[bool]],
693
+ expected_refresh_token_encrypted: bytes,
694
+ deadline: float | None = None,
695
+ ) -> Account | None:
696
+ """Persist freshly rotated tokens through a *guarded* compare-and-set only.
697
+
698
+ Returns the latest account row to adopt when a peer committed a
699
+ genuinely newer refresh-token rotation (different material) — that write
700
+ must never be clobbered. Returns ``None`` when our own rotation was
701
+ persisted (the caller then mirrors the new tokens onto its object), or
702
+ when the row vanished.
703
+
704
+ INVARIANT: there is NO unconditional token write anywhere in this
705
+ helper. Every persist is a compare-and-set conditioned on the exact
706
+ refresh-token ciphertext observed in the immediately-preceding read
707
+ (``WHERE refresh_token_encrypted == :expected``). That comparison is
708
+ atomic in the database, so there is no read->write gap: if ANYTHING
709
+ changed the row after our read — a non-deterministic re-encryption of the
710
+ same plaintext OR a genuine peer rotation — the guarded write MISSES and
711
+ clobbers nothing.
712
+
713
+ This structurally resolves the long-oscillating tension between "don't
714
+ drop the freshly-rotated token" and "don't clobber a genuine peer
715
+ rotation". The old exhaustion tail did an unconditional ``write(None)``
716
+ after a final plaintext-confirming re-read, which left a TOCTOU gap: a
717
+ peer rotation landing between that read and the unconditional write was
718
+ clobbered with a token minted from the already-consumed material.
719
+ Removing the unconditional write closes BOTH horns at once:
720
+
721
+ * (A) don't drop the freshly rotated token — when the stored plaintext is
722
+ confirmed to be the same token we consumed (only re-encrypted), we do
723
+ NOT give up: we retry the ciphertext-guarded CAS against the freshly
724
+ observed ciphertext so our new token still lands.
725
+ * (B) don't clobber a peer rotation — because every write is guarded, a
726
+ genuine rotation that lands in the former read->write gap now simply
727
+ causes a MISS, and we re-read, see the different plaintext, and ADOPT
728
+ it instead of overwriting.
729
+
730
+ On a guarded-write miss we re-read and decrypt the stored plaintext to
731
+ decide (never on the non-deterministic ciphertext):
732
+
733
+ * genuinely DIFFERENT valid plaintext -> a peer rotated -> ADOPT it
734
+ (return the peer row), never overwrite;
735
+ * SAME plaintext as the token we exchanged FROM (re-encryption noise of
736
+ the still-consumed token) -> retry the ciphertext-guarded CAS against
737
+ the newly observed ciphertext so our rotation wins;
738
+ * plaintext cannot be decrypted/compared -> raise the transient
739
+ ``token_persist_conflict`` (we cannot prove a retry is safe).
740
+
741
+ When the bounded guarded retries are exhausted OR the claim/caller
742
+ deadline cuts the retry loop mid-storm, we do NOT drop the freshly rotated
743
+ token: we run a DEDICATED small bounded retry loop
744
+ (``_FINAL_PERSIST_MAX_ATTEMPTS`` attempts with tiny backoff) that is
745
+ DELIBERATELY SEPARATE from the claim/caller deadline. Persisting a valid
746
+ single-use rotated token is worth a few extra milliseconds over budget,
747
+ because giving up here strands the account holding the already-consumed
748
+ token. Each final attempt is still a ciphertext-guarded compare-and-set
749
+ keyed on the freshly re-read ciphertext, so it is safe — it lands only if
750
+ nothing changed since that read (persisting our new token and evicting the
751
+ already-consumed one) and clobbers nothing otherwise. On each miss we
752
+ re-read and decide on the DECRYPTED plaintext: a genuinely different peer
753
+ plaintext is ADOPTED (never overwritten, done); the same plaintext merely
754
+ re-encrypted is RETRIED against the newly observed ciphertext. Because any
755
+ ciphertext change means a writer committed, and no realistic writer
756
+ re-encrypts the same consumed token in a tight loop, this lands within a
757
+ couple of attempts in practice.
758
+
759
+ SAFE TERMINAL OUTCOME. Only if the dedicated final retries are ALL
760
+ exhausted while the stored material stays the already-consumed token (a
761
+ truly pathological same-plaintext storm, or undecryptable stored material
762
+ — neither occurs in real production) do we give up — but we FAIL CLOSED
763
+ rather than surfacing ordinary transient contention. A bare transient
764
+ ``token_persist_conflict`` here would release the claim and let a later
765
+ blind retry re-exchange the still-stored consumed token, turning this
766
+ persistence race into an ``invalid_grant``/reauth PERMANENT knockout of a
767
+ healthy account. Instead we flag the account ``REAUTH_REQUIRED`` through
768
+ the SAME ciphertext-guarded status path (see
769
+ ``_flag_persist_conflict_reauth``): the dead stored token is explicitly
770
+ surfaced to operators, a peer rotation that lands in the guard window is
771
+ ADOPTED (never clobbered), and the account is never left silently holding
772
+ a consumed token that a blind retry would permanently knock out.
773
+
774
+ This is what ends the long "drop the rotated token vs clobber a peer
775
+ rotation" oscillation, and closes the irreducible trilemma corner
776
+ (never-clobber vs never-drop vs bounded-time) that only appears in a
777
+ same-plaintext storm where a writer re-encrypts the SAME consumed token
778
+ faster than our CAS lands, repeatedly: the dedicated retries make the
779
+ persist land in all realistic cases, and the safe terminal flag makes the
780
+ pathological corner recoverable instead of a knockout. Every write remains
781
+ a guarded compare-and-set, so nothing is ever clobbered.
782
+ """
783
+ consumed_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, expected_refresh_token_encrypted)
784
+ expected = expected_refresh_token_encrypted
785
+ deadline_elapsed = False
786
+ for _attempt in range(_TOKEN_CAS_MAX_ATTEMPTS):
787
+ if await write(expected):
788
+ # Guarded write landed: the row still held exactly the ciphertext
789
+ # we observed, so our freshly rotated token replaced it atomically
790
+ # and nothing was clobbered.
791
+ return None
792
+ latest = await self._repo.get_by_id_fresh(account.id)
793
+ if latest is None:
794
+ # Row is gone; nothing to persist or adopt.
795
+ return None
796
+ latest_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, latest.refresh_token_encrypted)
797
+ if consumed_plaintext is None or latest_plaintext is None:
798
+ # Cannot prove plaintext identity, so we cannot prove another
799
+ # guarded retry (or an adoption) is safe. A guarded persist was
800
+ # already ATTEMPTED just above (it missed), so surfacing a
801
+ # transient (non-permanent) error here is still a last resort, not
802
+ # a raise-in-place-of-persist. The caller retries once the
803
+ # contention clears; ``transport_error`` keeps it out of the
804
+ # permanent-failure cooldown cache.
805
+ logger.warning(
806
+ "Token-refresh compare-and-set for account_id=%s missed and the stored "
807
+ "refresh-token plaintext could not be decrypted for comparison; surfacing a "
808
+ "transient error rather than risking a clobber",
809
+ self._diagnostic_value(account.id),
810
+ )
811
+ raise RefreshError(
812
+ "token_persist_conflict",
813
+ (
814
+ f"Token-refresh compare-and-set for account_id={account.id} could not persist "
815
+ f"rotated tokens (stored refresh-token plaintext was undecryptable)"
816
+ ),
817
+ False,
818
+ transport_error=True,
819
+ )
820
+ if latest_plaintext != consumed_plaintext:
821
+ # A peer stored genuinely newer refresh-token material after our
822
+ # read; the guarded write MISSED and clobbered nothing. Adopt it
823
+ # rather than overwriting with the token we already consumed.
824
+ return _adopt_account_row(account, latest)
825
+ # Same refresh-token plaintext, re-encrypted concurrently: retry the
826
+ # guarded CAS against the freshly observed ciphertext so our rotation
827
+ # lands only while the consumed material is still stored. No
828
+ # unconditional write is ever issued.
829
+ expected = latest.refresh_token_encrypted
830
+ # The RETRY LOOP -- not the final safety persist below -- is bounded by
831
+ # the claim/caller deadline: a contended DB write must not keep the
832
+ # loop (and the held claim) spinning past the budget, after which a
833
+ # peer could win the claim and re-exchange the already-consumed token.
834
+ # On deadline expiry we STOP retrying but still fall through to the
835
+ # single final guarded persist so the rotated token is never dropped
836
+ # unpersisted.
837
+ if deadline is not None and time.monotonic() >= deadline:
838
+ deadline_elapsed = True
839
+ break
840
+
841
+ # FINAL, always-guarded persist of the freshly rotated token, keyed on the
842
+ # LAST-OBSERVED ciphertext. Reached when the bounded retries were exhausted
843
+ # OR the deadline cut the retry loop. This runs a DEDICATED small bounded
844
+ # retry loop that is DELIBERATELY SEPARATE from the claim/caller deadline:
845
+ # persisting a valid single-use rotated token is worth a few extra
846
+ # milliseconds over budget, because giving up here strands the account
847
+ # holding the already-consumed token. Each attempt is a single
848
+ # compare-and-set, so it lands only if nothing changed since the last read
849
+ # (persisting our new token and evicting the consumed one) and clobbers
850
+ # nothing otherwise; on each miss we re-read and decide on the decrypted
851
+ # plaintext (ADOPT a genuine peer rotation, retry a same-plaintext
852
+ # re-encryption against the newly observed ciphertext). Because any
853
+ # ciphertext change means a writer committed and no realistic writer
854
+ # re-encrypts the same consumed token in a tight loop, this lands within a
855
+ # couple of attempts in practice.
856
+ for final_attempt in range(_FINAL_PERSIST_MAX_ATTEMPTS):
857
+ if await write(expected):
858
+ return None
859
+ latest = await self._repo.get_by_id_fresh(account.id)
860
+ if latest is None:
861
+ # Row is gone; nothing to persist or adopt.
862
+ return None
863
+ latest_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, latest.refresh_token_encrypted)
864
+ if _is_genuine_peer_rotation(consumed_plaintext, latest_plaintext):
865
+ # A genuine peer rotation landed on the final re-read: ADOPT it (our
866
+ # freshly rotated token is legitimately superseded), never overwrite.
867
+ return _adopt_account_row(account, latest)
868
+ if consumed_plaintext is None or latest_plaintext is None:
869
+ # Cannot prove plaintext identity, so we cannot prove another
870
+ # guarded retry is safe. Stop the dedicated retries and fall
871
+ # through to the SAFE terminal outcome rather than spinning.
872
+ break
873
+ # Same refresh-token plaintext, re-encrypted concurrently: retry the
874
+ # guarded CAS against the freshly observed ciphertext so our rotation
875
+ # lands only while the consumed material is still stored.
876
+ expected = latest.refresh_token_encrypted
877
+ if final_attempt < _FINAL_PERSIST_MAX_ATTEMPTS - 1:
878
+ await asyncio.sleep(_FINAL_PERSIST_RETRY_BASE_SECONDS * (2**final_attempt))
879
+
880
+ # SAFE TERMINAL OUTCOME. The dedicated final retries were ALL exhausted
881
+ # while the stored material stayed the already-consumed token (a truly
882
+ # pathological same-plaintext storm, or undecryptable stored material).
883
+ # We must NOT surface an ordinary transient ``token_persist_conflict``
884
+ # here: that would release the claim and let a later blind retry re-read
885
+ # and re-exchange the still-stored consumed single-use token, turning this
886
+ # persistence race into an ``invalid_grant``/reauth PERMANENT knockout of
887
+ # an otherwise-healthy account. Fail CLOSED instead by flagging the account
888
+ # REAUTH_REQUIRED through the SAME ciphertext-guarded status path, so the
889
+ # dead stored token is explicitly surfaced to operators (never silently
890
+ # retried) and a peer rotation that lands in the guard window is still
891
+ # ADOPTED rather than clobbered.
892
+ return await self._flag_persist_conflict_reauth(
893
+ account,
894
+ expected_refresh_token_encrypted=expected,
895
+ consumed_plaintext=consumed_plaintext,
896
+ deadline_elapsed=deadline_elapsed,
897
+ )
898
+
899
+ async def _flag_persist_conflict_reauth(
900
+ self,
901
+ account: Account,
902
+ *,
903
+ expected_refresh_token_encrypted: bytes,
904
+ consumed_plaintext: str | None,
905
+ deadline_elapsed: bool,
906
+ ) -> Account | None:
907
+ """SAFE terminal outcome for a truly pathological final-persist storm.
908
+
909
+ Reached only when the DEDICATED bounded final-persist retries were ALL
910
+ exhausted while the database kept holding the already-consumed refresh
911
+ token (a sustained same-plaintext re-encryption storm, or undecryptable
912
+ stored material — neither occurs in real production, since token
913
+ re-encryption is a one-time re-auth/import event, not a tight loop).
914
+
915
+ Rather than surfacing a bare transient ``token_persist_conflict`` — which
916
+ releases the claim and lets a later blind retry re-exchange the stored
917
+ consumed token into an ``invalid_grant``/reauth PERMANENT knockout of a
918
+ healthy account — this fails CLOSED by flagging the account
919
+ ``REAUTH_REQUIRED`` through the SAME ciphertext-guarded status CAS keyed on
920
+ the last-observed (consumed-token) ciphertext:
921
+
922
+ * guarded CAS LANDS -> the account is EXPLICITLY flagged for re-auth (its
923
+ durable state truly holds a dead token), so operators repair it instead
924
+ of a blind retry silently knocking it out. Return the flagged row.
925
+ * guarded CAS MISSES on a genuinely DIFFERENT peer plaintext -> a peer
926
+ re-authenticated/rotated; the account is repaired, so ADOPT the peer row
927
+ (never clobbered), do not flag reauth.
928
+ * guarded CAS keeps missing on same-plaintext re-encryption through its
929
+ own bounded budget -> only THEN surface the transient
930
+ ``token_persist_conflict`` as the last resort (no worse than before, and
931
+ astronomically unlikely). ``transport_error`` keeps it out of the
932
+ permanent-failure cooldown cache.
933
+
934
+ Every write is a guarded compare-and-set, so a genuine peer rotation is
935
+ never overwritten in any branch.
936
+ """
937
+ status = AccountStatus.REAUTH_REQUIRED
938
+ reason = "Refresh token persistence conflict; stored token is stale - re-login required"
939
+ expected = expected_refresh_token_encrypted
940
+ for _attempt in range(_FINAL_PERSIST_MAX_ATTEMPTS):
941
+ latest = await self._repo.get_by_id_fresh(account.id)
942
+ if latest is None:
943
+ # Row is gone; nothing to flag or adopt.
944
+ return None
945
+ latest_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, latest.refresh_token_encrypted)
946
+ if _is_genuine_peer_rotation(consumed_plaintext, latest_plaintext):
947
+ # A peer rotated genuinely newer material: the account is repaired.
948
+ # ADOPT it; do NOT flag reauth on a healthy rotated row.
949
+ return _adopt_account_row(account, latest)
950
+ expected = latest.refresh_token_encrypted
951
+ applied = await self._repo.update_status_if_current(
952
+ account.id,
953
+ status,
954
+ reason,
955
+ expected_status=latest.status,
956
+ expected_deactivation_reason=latest.deactivation_reason,
957
+ expected_reset_at=latest.reset_at,
958
+ expected_refresh_token_encrypted=expected,
959
+ )
960
+ if applied:
961
+ account.status = status
962
+ account.deactivation_reason = reason
963
+ get_account_selection_cache().invalidate()
964
+ logger.warning(
965
+ "Token-refresh compare-and-set for account_id=%s could not persist the freshly "
966
+ "rotated token after the dedicated final-persist retries (%s); flagged the account "
967
+ "REAUTH_REQUIRED via the guarded status path so it is explicitly repaired rather "
968
+ "than left holding a consumed token that a blind retry would permanently knock out",
969
+ self._diagnostic_value(account.id),
970
+ "claim/caller deadline elapsed" if deadline_elapsed else "same-plaintext storm exhausted",
971
+ )
972
+ return account
973
+ # Guarded status CAS missed on unchanged material; re-read and retry.
974
+ # Even the guarded reauth flag could not land on unchanged material through
975
+ # its own bounded budget: surface the transient (non-permanent) conflict as
976
+ # the last resort. ``transport_error`` keeps it out of the permanent-failure
977
+ # cooldown cache and never risks a clobber.
978
+ logger.warning(
979
+ "Token-refresh compare-and-set for account_id=%s could not persist the freshly rotated "
980
+ "token nor flag REAUTH_REQUIRED after the dedicated final-persist retries; surfacing a "
981
+ "transient conflict so the caller retries the whole refresh rather than risking a clobber",
982
+ self._diagnostic_value(account.id),
983
+ )
984
+ raise RefreshError(
985
+ "token_persist_conflict",
986
+ (
987
+ f"Token-refresh compare-and-set for account_id={account.id} could not persist "
988
+ f"rotated tokens nor flag REAUTH_REQUIRED after a final guarded attempt missed on "
989
+ f"unchanged material"
990
+ ),
991
+ False,
992
+ transport_error=True,
993
+ )
994
+
995
+ async def _handle_permanent_refresh_failure(
996
+ self,
997
+ account: Account,
998
+ exc: RefreshError,
999
+ attempted_fingerprint: str,
1000
+ *,
1001
+ deadline: float | None = None,
1002
+ ) -> Account | None:
1003
+ """Persist a permanent refresh failure without clobbering a concurrent rotation.
1004
+
1005
+ Returns the latest account row when its refresh-token material rotated
1006
+ after this attempt began (the caller adopts it instead of raising);
1007
+ returns ``None`` when the permanent failure stands (the status CAS
1008
+ landed, or the row vanished). Raises a TRANSIENT ``RefreshError``
1009
+ (``transport_error=True``, non-permanent) when the status-downgrade CAS
1010
+ is EXHAUSTED by contention while the account still holds the failed
1011
+ material — we could not authoritatively persist ``REAUTH_REQUIRED``, so
1012
+ the caller must retry rather than fall back to the unguarded
1013
+ ``LoadBalancer.mark_permanent_failure()`` write that could clobber a
1014
+ peer rotation landing in the same window. The comparison uses the
1015
+ fingerprint of the material this attempt exchanged, captured before the
1016
+ fresh re-read, because ``get_by_id_fresh`` may refresh the caller's own
1017
+ identity-map object in place.
1018
+
1019
+ The status downgrade uses a compare-and-set conditioned on the freshly
1020
+ observed account state including the refresh-token ciphertext: a
1021
+ concurrent re-auth/import can change that ciphertext between the fresh
1022
+ re-read and the write. As with token persistence, a ciphertext change is
1023
+ not by itself a newer rotation — Fernet is non-deterministic, so a
1024
+ re-auth/import that re-encrypts the SAME plaintext changes the bytes
1025
+ without issuing a new token. When the fingerprint is still unchanged the
1026
+ account is holding the very material that just failed permanently, so we
1027
+ re-read and retry the CAS (bounded) against the freshly observed
1028
+ ciphertext rather than skipping the downgrade and leaving the account
1029
+ active with dead credentials. Only a genuinely different fingerprint is
1030
+ adopted as a peer rotation.
1031
+ """
1032
+ latest = await self._repo.get_by_id_fresh(account.id)
1033
+ if latest is None:
1034
+ # Account row is gone; nothing to downgrade.
1035
+ return None
1036
+ if (
1037
+ _refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
1038
+ != attempted_fingerprint
1039
+ ):
1040
+ return _adopt_account_row(account, latest)
1041
+ reason = PERMANENT_FAILURE_CODES.get(exc.code, exc.message)
1042
+ status = account_status_for_permanent_failure(exc.code)
1043
+ for attempt in range(_TOKEN_CAS_MAX_ATTEMPTS):
1044
+ # The FIRST status CAS always runs so a genuine permanent failure is
1045
+ # persisted best-effort. The RETRIES are additionally bounded by the
1046
+ # claim/caller deadline (not just the attempt count): the status
1047
+ # write loop runs while the claim is held, and a contended DB write
1048
+ # must not keep looping -- and holding the claim -- past the budget.
1049
+ # On deadline expiry surface the transient status-downgrade conflict
1050
+ # so the claim is released and the caller retries rather than running
1051
+ # the unguarded permanent mark that could clobber a peer rotation.
1052
+ if attempt > 0 and deadline is not None and time.monotonic() >= deadline:
1053
+ logger.warning(
1054
+ "Permanent refresh-failure status CAS for account_id=%s code=%s exceeded the "
1055
+ "claim/caller deadline after %d attempt(s); surfacing a transient conflict and "
1056
+ "releasing the claim rather than looping past the budget",
1057
+ self._diagnostic_value(account.id),
1058
+ exc.code,
1059
+ attempt,
1060
+ )
1061
+ raise RefreshError(
1062
+ "status_downgrade_conflict",
1063
+ (
1064
+ f"Permanent refresh-failure status downgrade for account_id={account.id} "
1065
+ f"(code={exc.code}) exceeded the refresh claim/caller deadline before it "
1066
+ f"could persist REAUTH_REQUIRED"
1067
+ ),
1068
+ False,
1069
+ transport_error=True,
1070
+ ) from exc
1071
+ applied = await self._repo.update_status_if_current(
1072
+ account.id,
1073
+ status,
1074
+ reason,
1075
+ expected_status=latest.status,
1076
+ expected_deactivation_reason=latest.deactivation_reason,
1077
+ expected_reset_at=latest.reset_at,
1078
+ expected_refresh_token_encrypted=latest.refresh_token_encrypted,
1079
+ )
1080
+ if applied:
1081
+ account.status = status
1082
+ account.deactivation_reason = reason
1083
+ if status == AccountStatus.DEACTIVATED:
1084
+ mark_account_routing_unavailable(account.id)
1085
+ get_account_selection_cache().invalidate()
1086
+ return None
1087
+ # CAS missed: the freshly observed account state changed between the
1088
+ # re-read and the write. Re-read to decide why.
1089
+ latest = await self._repo.get_by_id_fresh(account.id)
1090
+ if latest is None:
1091
+ return None
1092
+ if (
1093
+ _refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
1094
+ != attempted_fingerprint
1095
+ ):
1096
+ # A concurrent re-auth/import committed a genuinely different
1097
+ # refresh token in the CAS window. The account is repaired;
1098
+ # adopt the freshly rotated row (mirroring the pre-CAS check
1099
+ # above) instead of re-raising. Returning ``None`` here would
1100
+ # make ``_perform_refresh`` re-raise the original permanent
1101
+ # ``RefreshError``, and proxy callers then commonly invoke
1102
+ # ``LoadBalancer.mark_permanent_failure()`` whose ``update_status``
1103
+ # path is NOT guarded by this refresh-token CAS — so it would
1104
+ # clobber the peer's valid rotation with ``REAUTH_REQUIRED`` and
1105
+ # tear down sessions for an account that was just repaired.
1106
+ return _adopt_account_row(account, latest)
1107
+ # Same refresh-token plaintext, merely re-encrypted (non-deterministic
1108
+ # Fernet) — or an unrelated status/reason/reset nudge. The account is
1109
+ # still holding the material that just failed permanently, so retry
1110
+ # the CAS against the freshly observed ciphertext so the downgrade
1111
+ # lands rather than leaving a dead account active.
1112
+ # The bounded status-downgrade compare-and-set never landed: the account
1113
+ # still holds the material that failed permanently, but a sustained
1114
+ # re-encryption storm kept changing the observed ciphertext so we could
1115
+ # not authoritatively persist REAUTH_REQUIRED. Returning ``None`` here
1116
+ # would make ``_perform_refresh`` re-raise the ORIGINAL permanent
1117
+ # ``RefreshError``; proxy callers then commonly invoke
1118
+ # ``LoadBalancer.mark_permanent_failure()`` whose ``update_status`` path
1119
+ # is NOT guarded by the refresh-token ciphertext CAS — so a genuine peer
1120
+ # re-auth/import rotation that lands after our final re-read but before
1121
+ # that fallback write would be clobbered with ``REAUTH_REQUIRED``,
1122
+ # exactly the repaired-account clobber the CAS guards exist to prevent.
1123
+ # Since we could not win an atomic CAS window, surface a transient
1124
+ # (non-permanent) error so the caller RETRIES the whole refresh once the
1125
+ # contention clears rather than running the unguarded permanent mark.
1126
+ # ``transport_error`` keeps it out of the permanent-failure cooldown
1127
+ # cache. A genuinely different peer rotation is still adopted above (that
1128
+ # stays a repair); a status CAS that SUCCEEDS above still stands as a
1129
+ # real permanent failure — only contention-driven exhaustion becomes
1130
+ # transient here.
1131
+ logger.warning(
1132
+ "Permanent refresh-failure status CAS for account_id=%s code=%s kept missing on "
1133
+ "unchanged token material after %d attempts; surfacing a transient error so the caller "
1134
+ "retries rather than running the unguarded permanent-failure mark that could clobber a "
1135
+ "concurrent peer rotation",
1136
+ self._diagnostic_value(account.id),
1137
+ exc.code,
1138
+ _TOKEN_CAS_MAX_ATTEMPTS,
1139
+ )
1140
+ raise RefreshError(
1141
+ "status_downgrade_conflict",
1142
+ (
1143
+ f"Permanent refresh-failure status downgrade for account_id={account.id} "
1144
+ f"(code={exc.code}) could not persist REAUTH_REQUIRED after "
1145
+ f"{_TOKEN_CAS_MAX_ATTEMPTS} attempts due to concurrent writes"
1146
+ ),
1147
+ False,
1148
+ transport_error=True,
1149
+ ) from exc
1150
+
1151
+ async def _refresh_tokens(self, refresh_token: str, *, account: Account) -> TokenRefreshResult:
1152
+ # Bound the ENTIRE claim-holding work of this body — the token-refresh
1153
+ # admission wait AND the upstream OAuth exchange — by the caller's
1154
+ # remaining refresh budget. This runs inside the shielded singleflight
1155
+ # task that outlives a cancelled caller and holds the cross-replica DB
1156
+ # refresh claim, so any unbounded wait here keeps the claim held past the
1157
+ # request deadline and blocks peer replicas on the same account's claim.
1158
+ #
1159
+ # ``token_refresh_timeout_override`` (set by the claim path to the
1160
+ # remaining budget) only caps the HTTP exchange; without help,
1161
+ # ``WorkAdmissionController`` waits up to
1162
+ # ``ADMISSION_WAIT_TIMEOUT_SECONDS`` for a slot on a saturated
1163
+ # token-refresh semaphore BEFORE that HTTP timeout is even armed. Derive
1164
+ # one monotonic deadline from the budget and enforce it on BOTH the
1165
+ # admission acquire and the exchange so their sum cannot overrun it.
1166
+ budget = get_token_refresh_timeout_override()
1167
+ deadline = time.monotonic() + max(0.0, budget) if budget is not None else None
1168
+
1169
+ refresh_lease = await self._acquire_refresh_admission_bounded(account, deadline)
1170
+ try:
1171
+ async with get_background_session() as session:
1172
+ try:
1173
+ route = await resolve_upstream_route(
1174
+ session,
1175
+ account_id=account.id,
1176
+ operation="token_refresh",
1177
+ scope="account",
1178
+ encryptor=self._encryptor,
1179
+ )
1180
+ except UpstreamProxyRouteError as exc:
1181
+ raise RefreshError(
1182
+ "upstream_proxy_unavailable",
1183
+ f"Upstream proxy route unavailable: {exc.reason}",
1184
+ False,
1185
+ transport_error=True,
1186
+ upstream_proxy_fail_closed_reason=exc.reason,
1187
+ ) from exc
1188
+ if route is None and await _account_has_active_proxy_binding(session, account.id):
1189
+ raise RefreshError(
1190
+ "upstream_proxy_unavailable",
1191
+ "Account has an active proxy binding but no route resolved",
1192
+ False,
1193
+ transport_error=True,
1194
+ upstream_proxy_fail_closed_reason="binding_route_unavailable",
1195
+ )
1196
+ # Cap the exchange to what is actually LEFT after the admission wait
1197
+ # (and route resolution). The pushed override still carries the full
1198
+ # pre-admission remaining budget, so re-derive it here; otherwise a
1199
+ # long admission wait followed by a full-remaining-budget exchange
1200
+ # could hold the claim for up to twice the budget.
1201
+ exchange_override: contextvars.Token[float | None] | None = None
1202
+ if deadline is not None:
1203
+ remaining = deadline - time.monotonic()
1204
+ if remaining <= 0:
1205
+ raise RefreshError(
1206
+ "refresh_claim_timeout",
1207
+ f"Token refresh for account {account.id} exhausted its refresh budget after "
1208
+ f"acquiring token-refresh admission but before the upstream exchange could start",
1209
+ False,
1210
+ transport_error=True,
1211
+ )
1212
+ exchange_override = push_token_refresh_timeout_override(remaining)
1213
+ try:
1214
+ return await _call_with_supported_optional_kwargs(
1215
+ refresh_access_token,
1216
+ refresh_token,
1217
+ optional_kwargs={
1218
+ "route": route,
1219
+ "allow_direct_egress": route is None,
1220
+ },
1221
+ )
1222
+ finally:
1223
+ if exchange_override is not None:
1224
+ pop_token_refresh_timeout_override(exchange_override)
1225
+ finally:
1226
+ if refresh_lease is not None:
1227
+ refresh_lease.release()
1228
+
1229
+ async def _acquire_refresh_admission_bounded(
1230
+ self,
1231
+ account: Account,
1232
+ deadline: float | None,
1233
+ ) -> RefreshAdmissionLeasePort | None:
1234
+ """Acquire token-refresh admission without overrunning the caller budget.
1235
+
1236
+ ``WorkAdmissionController`` waits up to
1237
+ ``ADMISSION_WAIT_TIMEOUT_SECONDS`` for a slot on a saturated
1238
+ token-refresh semaphore. That wait happens while this shielded task
1239
+ already holds the cross-replica DB refresh claim, so it MUST be capped by
1240
+ the caller's remaining refresh budget (``deadline``): otherwise a
1241
+ small-budget request would hold the claim — and block peer replicas on
1242
+ the same account — for the full admission timeout. When the budget is
1243
+ already exhausted before/at admission, fail fast with the transient
1244
+ ``refresh_claim_timeout`` so the claim is released (by the caller's
1245
+ ``finally``) rather than held for the full wait.
1246
+ """
1247
+ if self._acquire_refresh_admission is None:
1248
+ return None
1249
+ if deadline is None:
1250
+ return await self._acquire_refresh_admission()
1251
+ admission_wait = deadline - time.monotonic()
1252
+ if admission_wait <= 0:
1253
+ raise RefreshError(
1254
+ "refresh_claim_timeout",
1255
+ f"Token refresh for account {account.id} exhausted its refresh budget before "
1256
+ f"token-refresh admission could be acquired",
1257
+ False,
1258
+ transport_error=True,
1259
+ )
1260
+ try:
1261
+ return await asyncio.wait_for(self._acquire_refresh_admission(), timeout=admission_wait)
1262
+ except asyncio.TimeoutError as exc:
1263
+ raise RefreshError(
1264
+ "refresh_claim_timeout",
1265
+ f"Token refresh for account {account.id} exhausted its refresh budget waiting for "
1266
+ f"token-refresh admission on a saturated concurrency gate",
1267
+ False,
1268
+ transport_error=True,
1269
+ ) from exc
1270
+
1271
+ async def _ensure_chatgpt_account_id(self, account: Account) -> Account:
1272
+ if account.chatgpt_account_id:
1273
+ return account
1274
+ try:
1275
+ id_token = self._encryptor.decrypt(account.id_token_encrypted)
1276
+ except Exception:
1277
+ return account
1278
+ raw_account_id = _chatgpt_account_id_from_id_token(id_token)
1279
+ if not raw_account_id:
1280
+ return account
1281
+
1282
+ account.chatgpt_account_id = raw_account_id
1283
+ try:
1284
+ # This backfill runs on EVERY ensure_fresh for a legacy account (the
1285
+ # fast, no-refresh path included) against the caller's selection-time
1286
+ # snapshot, OUTSIDE any refresh claim. It derives and persists only
1287
+ # the missing chatgpt_account_id, so it routes through the
1288
+ # metadata-only writer, which STRUCTURALLY cannot write token
1289
+ # ciphertext. That makes it impossible for this stale in-memory
1290
+ # snapshot to rewrite the refresh-token material and clobber a peer
1291
+ # replica's concurrent single-use rotation -- the exact lost-update
1292
+ # the refresh persist path was rebuilt to forbid, which previously
1293
+ # re-entered via this sibling.
1294
+ await self._repo.update_account_metadata(
1295
+ account.id,
1296
+ chatgpt_account_id=raw_account_id,
1297
+ )
1298
+ except Exception:
1299
+ logger.warning(
1300
+ "Failed to persist chatgpt_account_id account_id=%s",
1301
+ self._diagnostic_value(account.id),
1302
+ exc_info=None if self._redact_sensitive_details else True,
1303
+ )
1304
+ return account
1305
+
1306
+
1307
+ def _chatgpt_account_id_from_id_token(id_token: str) -> str | None:
1308
+ claims = extract_id_token_claims(id_token)
1309
+ auth_claims = claims.auth or OpenAIAuthClaims()
1310
+ return auth_claims.chatgpt_account_id or claims.chatgpt_account_id
1311
+
1312
+
1313
+ def _refresh_singleflight_key(
1314
+ encryptor: TokenEncryptor,
1315
+ account: Account,
1316
+ ) -> _RefreshSingleflightKey:
1317
+ return (
1318
+ account.id,
1319
+ _refresh_token_material_fingerprint(encryptor, account.refresh_token_encrypted),
1320
+ )
1321
+
1322
+
1323
+ def _adopt_account_row(target: Account, source: Account) -> Account:
1324
+ """Copy a concurrently committed row's state onto the caller's account object.
1325
+
1326
+ ``source`` is attached to the refresh task's short-lived session; returning
1327
+ it directly would hand callers an object that expires when that session
1328
+ closes. Copying onto the caller's object mirrors how a successful refresh
1329
+ reports its result.
1330
+ """
1331
+ if target is source:
1332
+ return target
1333
+ for column in Account.__table__.columns:
1334
+ if column.name in ("id", "created_at"):
1335
+ continue
1336
+ setattr(target, column.name, getattr(source, column.name))
1337
+ return target
1338
+
1339
+
1340
+ def _terminal_status_refresh_error(account: Account) -> RefreshError:
1341
+ """Build the PERMANENT ``RefreshError`` that surfaces a prior holder's terminal status.
1342
+
1343
+ Reached only when a claim waiter wins the released claim and re-reads a row
1344
+ whose refresh material is UNCHANGED but whose status is already terminal
1345
+ (a prior holder committed REAUTH_REQUIRED/DEACTIVATED without rotating the
1346
+ token). The chosen code maps back to the SAME committed status through
1347
+ :func:`account_status_for_permanent_failure`, so a downstream
1348
+ ``LoadBalancer.mark_permanent_failure`` re-affirms the terminal state rather
1349
+ than flipping REAUTH_REQUIRED to DEACTIVATED (or vice versa).
1350
+ """
1351
+ code = "account_deactivated" if account.status == AccountStatus.DEACTIVATED else "refresh_token_invalidated"
1352
+ reason = account.deactivation_reason or PERMANENT_FAILURE_CODES.get(code, "re-login required")
1353
+ return RefreshError(
1354
+ code,
1355
+ (
1356
+ f"Token refresh for account {account.id} not attempted: a prior refresh-claim holder "
1357
+ f"already flagged the account {account.status.value} without rotating the refresh token "
1358
+ f"({reason}); reusing the unchanged consumed token would repeat a permanent failure"
1359
+ ),
1360
+ True,
1361
+ )
1362
+
1363
+
1364
+ def _refresh_token_material_fingerprint(encryptor: TokenEncryptor, refresh_token_encrypted: bytes) -> str:
1365
+ try:
1366
+ material = encryptor.decrypt(refresh_token_encrypted).encode("utf-8")
1367
+ except Exception:
1368
+ material = refresh_token_encrypted
1369
+ return sha256(material).hexdigest()
1370
+
1371
+
1372
+ def _decrypt_refresh_token_plaintext(encryptor: TokenEncryptor, refresh_token_encrypted: bytes) -> str | None:
1373
+ """Decrypt refresh-token ciphertext to its plaintext, or ``None`` if undecryptable.
1374
+
1375
+ Unlike :func:`_refresh_token_material_fingerprint` (which silently falls back
1376
+ to hashing the raw ciphertext when decryption fails), this returns ``None`` so
1377
+ callers can distinguish a genuine plaintext comparison from an impossible one.
1378
+ """
1379
+ try:
1380
+ return encryptor.decrypt(refresh_token_encrypted)
1381
+ except Exception:
1382
+ return None
1383
+
1384
+
1385
+ def _is_genuine_peer_rotation(consumed_plaintext: str | None, latest_plaintext: str | None) -> bool:
1386
+ """True when the freshly re-read plaintext is a genuinely DIFFERENT refresh
1387
+ token than the one this attempt exchanged FROM (a peer rotation to ADOPT),
1388
+ rather than the same consumed token merely re-encrypted or an undecryptable
1389
+ blob. Both plaintexts must decrypt for the comparison to be trustworthy."""
1390
+ return consumed_plaintext is not None and latest_plaintext is not None and latest_plaintext != consumed_plaintext
1391
+
1392
+
1393
+ def _clean_optional(value: str | None) -> str | None:
1394
+ if not isinstance(value, str):
1395
+ return None
1396
+ cleaned = value.strip()
1397
+ return cleaned or None
1398
+
1399
+
1400
+ async def _call_with_supported_optional_kwargs(
1401
+ func: Callable[..., Awaitable[Any]],
1402
+ /,
1403
+ *args: Any,
1404
+ optional_kwargs: Mapping[str, Any],
1405
+ **required_kwargs: Any,
1406
+ ) -> Any:
1407
+ kwargs = dict(required_kwargs)
1408
+ kwargs.update(optional_kwargs)
1409
+ try:
1410
+ signature = inspect.signature(func)
1411
+ except (TypeError, ValueError):
1412
+ signature = None
1413
+ accepts_var_keyword = signature is not None and any(
1414
+ parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()
1415
+ )
1416
+ if signature is not None and not accepts_var_keyword:
1417
+ for name in optional_kwargs:
1418
+ if name not in signature.parameters:
1419
+ kwargs.pop(name, None)
1420
+ return await func(*args, **kwargs)
1421
+
1422
+
1423
+ def _clear_refresh_singleflight_state() -> None:
1424
+ _REFRESH_SINGLEFLIGHT.clear()
1425
+
1426
+
1427
+ async def _account_has_active_proxy_binding(session: AsyncSession, account_id: str) -> bool:
1428
+ try:
1429
+ result = await session.execute(
1430
+ select(AccountProxyBinding.id)
1431
+ .where(
1432
+ AccountProxyBinding.account_id == account_id,
1433
+ AccountProxyBinding.is_active.is_(True),
1434
+ )
1435
+ .limit(1)
1436
+ )
1437
+ return result.scalar_one_or_none() is not None
1438
+ except OperationalError:
1439
+ return False