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,1488 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import math
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Collection, Coroutine, Hashable
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Final, Mapping, Protocol, cast
12
+
13
+ from app.core import usage as usage_core
14
+ from app.core.auth.refresh import RefreshError
15
+ from app.core.balancer import (
16
+ PERMANENT_FAILURE_CODES,
17
+ QUOTA_EXCEEDED_COOLDOWN_SECONDS,
18
+ RATE_LIMITED_MIN_COOLDOWN_SECONDS,
19
+ account_status_for_permanent_failure,
20
+ plausible_rate_limit_reset_at,
21
+ )
22
+ from app.core.clients.usage import UsageFetchError, fetch_usage
23
+ from app.core.crypto import TokenEncryptor
24
+ from app.core.plan_types import ACCOUNT_PLAN_TYPES, coerce_account_plan_type, normalize_account_plan_type
25
+ from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route
26
+ from app.core.usage.models import AdditionalRateLimitPayload, UsagePayload, UsageWindow
27
+ from app.core.usage.refresh_policy import USAGE_REFRESH_INTERVAL_SECONDS
28
+ from app.core.utils.request_id import get_request_id
29
+ from app.core.utils.shared_future import wait_on_shared_future
30
+ from app.core.utils.time import utcnow
31
+ from app.db.models import Account, AccountStatus, UsageHistory
32
+ from app.db.session import get_background_session
33
+ from app.modules.accounts.auth_manager import AccountsRepositoryPort, AuthManager, _clean_optional
34
+ from app.modules.accounts.background_repository import BackgroundAccountsRepository
35
+ from app.modules.proxy.account_cache import get_account_selection_cache, mark_account_routing_unavailable
36
+ from app.modules.usage.additional_quota_keys import canonicalize_additional_quota_key
37
+ from app.modules.usage.background_repository import BackgroundAdditionalUsageRepository, BackgroundUsageRepository
38
+ from app.modules.usage.plan_downgrade_observations import (
39
+ InMemoryPlanDowngradeObservationStore,
40
+ PlanDowngradeObservationStorePort,
41
+ credential_fingerprint,
42
+ get_plan_downgrade_observation_store,
43
+ )
44
+ from app.modules.usage.repository import AdditionalUsageRepository, UsageWindowWrite
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ class UsageRepositoryPort(Protocol):
50
+ async def latest_entry_for_account(
51
+ self,
52
+ account_id: str,
53
+ *,
54
+ window: str | None = None,
55
+ ) -> UsageHistory | None: ...
56
+
57
+ async def add_account_snapshot(
58
+ self,
59
+ account_id: str,
60
+ windows: Collection[UsageWindowWrite],
61
+ *,
62
+ recorded_at: datetime | None = None,
63
+ ) -> list[UsageHistory]: ...
64
+
65
+
66
+ class AdditionalUsageRepositoryPort(Protocol):
67
+ async def add_entry(
68
+ self,
69
+ account_id: str,
70
+ limit_name: str,
71
+ metered_feature: str,
72
+ window: str,
73
+ used_percent: float,
74
+ reset_at: int | None = None,
75
+ window_minutes: int | None = None,
76
+ recorded_at: datetime | None = None,
77
+ quota_key: str | None = None,
78
+ ) -> None: ...
79
+
80
+ async def delete_for_account(self, account_id: str) -> None: ...
81
+
82
+ async def delete_for_account_and_quota_key(self, account_id: str, quota_key: str) -> None: ...
83
+
84
+ async def delete_for_account_and_limit(self, account_id: str, limit_name: str) -> None: ...
85
+
86
+ async def delete_for_account_quota_key_window(
87
+ self,
88
+ account_id: str,
89
+ quota_key: str,
90
+ window: str,
91
+ ) -> None: ...
92
+
93
+ async def delete_for_account_limit_window(
94
+ self,
95
+ account_id: str,
96
+ limit_name: str,
97
+ window: str,
98
+ ) -> None: ...
99
+
100
+ async def list_quota_keys(
101
+ self,
102
+ *,
103
+ account_ids: Collection[str] | None = None,
104
+ since: datetime | None = None,
105
+ ) -> list[str]: ...
106
+
107
+ async def list_limit_names(
108
+ self,
109
+ *,
110
+ account_ids: Collection[str] | None = None,
111
+ since: datetime | None = None,
112
+ ) -> list[str]: ...
113
+
114
+ async def latest_recorded_at_for_account(self, account_id: str) -> datetime | None: ...
115
+
116
+
117
+ class AccountsRepositoryWithStatusComparePort(AccountsRepositoryPort, Protocol):
118
+ async def update_status_if_current(
119
+ self,
120
+ account_id: str,
121
+ status: AccountStatus,
122
+ deactivation_reason: str | None = None,
123
+ reset_at: int | None = None,
124
+ blocked_at: int | None = None,
125
+ *,
126
+ expected_status: AccountStatus,
127
+ expected_deactivation_reason: str | None = None,
128
+ expected_reset_at: int | None = None,
129
+ expected_blocked_at: int | None = None,
130
+ expected_refresh_token_encrypted: bytes | None = None,
131
+ ) -> bool: ...
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class AccountRefreshResult:
136
+ usage_written: bool
137
+ fetch_succeeded: bool = True
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class _MergedAdditionalWindow:
142
+ limit_name: str
143
+ metered_feature: str
144
+ used_percent: float
145
+ reset_at: int | None
146
+ window_minutes: int | None
147
+
148
+
149
+ # Module-level freshness cache for additional-only accounts (no main UsageHistory
150
+ # entry). Used as a fast path to avoid DB queries on every pass within the same
151
+ # process. Updated only after a successful refresh that wrote data.
152
+ _last_successful_refresh: dict[str, datetime] = {}
153
+ _usage_refresh_auth_cooldowns: dict[str, float] = {}
154
+
155
+ # Debounce window for request-triggered refreshes (a streamed
156
+ # ``usage_limit_reached``): one immediate upstream fetch per account per
157
+ # window collapses a 429 storm into a single call. Deliberately a constant
158
+ # rather than a CODEX_LB_* setting (PRINCIPLES.md P2).
159
+ _REQUEST_REFRESH_DEBOUNCE_SECONDS: Final[float] = 15.0
160
+ # Accounts whose usage fetch failed with an ambiguous 401/403 are skipped for
161
+ # this long before the next attempt (fixed; issue #1340 / PRINCIPLES.md P2).
162
+ _USAGE_REFRESH_AUTH_FAILURE_COOLDOWN_SECONDS = 300.0
163
+ _usage_request_refresh_deadlines: dict[str, float] = {}
164
+
165
+ # Fallback for consecutive workspace-less "free" observations (issue #1456) used
166
+ # only when persistence is explicitly disabled -- the DB-less unit-test harness.
167
+ # The process default is the database-backed
168
+ # :class:`PlanDowngradeObservationStore`, so the observation sequence stays
169
+ # coherent across replicas sharing one database; this fallback preserves
170
+ # single-process behavior instead of dropping the guard when no database is
171
+ # available.
172
+ _FALLBACK_PLAN_DOWNGRADE_OBSERVATIONS = InMemoryPlanDowngradeObservationStore()
173
+
174
+ # Number of consecutive agreeing observations required before a workspace-less
175
+ # paid -> free downgrade is persisted. Deliberately a constant rather than a
176
+ # CODEX_LB_* setting: two observations is the minimum that distinguishes a real
177
+ # expiry from a single degraded response, and operators gain nothing from tuning
178
+ # it (PRINCIPLES.md P2, settings-surface ratchet in issue #1340).
179
+ _FREE_PLAN_DOWNGRADE_CONFIRMATIONS: Final[int] = 2
180
+
181
+
182
+ class _UsageRefreshSingleflight:
183
+ def __init__(self) -> None:
184
+ self._inflight: dict[Hashable, asyncio.Task[AccountRefreshResult]] = {}
185
+ self._lock = asyncio.Lock()
186
+
187
+ async def run(
188
+ self,
189
+ account_id: Hashable,
190
+ factory: Callable[[], Awaitable[AccountRefreshResult]],
191
+ *,
192
+ join_existing: bool = True,
193
+ ) -> AccountRefreshResult:
194
+ while True:
195
+ wait_for_existing: asyncio.Task[AccountRefreshResult] | None = None
196
+ async with self._lock:
197
+ task = self._inflight.get(account_id)
198
+ if task is None or task.done():
199
+ task = asyncio.create_task(self._run_factory(factory))
200
+ self._inflight[account_id] = task
201
+ task.add_done_callback(
202
+ lambda done, *, key=account_id: self._clear_if_current(key, done),
203
+ )
204
+ break
205
+ if join_existing:
206
+ break
207
+ wait_for_existing = task
208
+ if wait_for_existing is None:
209
+ break
210
+ try:
211
+ await wait_on_shared_future(wait_for_existing)
212
+ except asyncio.CancelledError:
213
+ current_task = asyncio.current_task()
214
+ if current_task is not None and current_task.cancelling():
215
+ raise
216
+ except Exception:
217
+ pass
218
+ return await wait_on_shared_future(task)
219
+
220
+ async def _run_factory(
221
+ self,
222
+ factory: Callable[[], Awaitable[AccountRefreshResult]],
223
+ ) -> AccountRefreshResult:
224
+ return await factory()
225
+
226
+ def _clear_if_current(
227
+ self,
228
+ key: Hashable,
229
+ task: asyncio.Task[AccountRefreshResult],
230
+ ) -> None:
231
+ current = self._inflight.get(key)
232
+ if current is task:
233
+ self._inflight.pop(key, None)
234
+ if task.cancelled():
235
+ return
236
+ with contextlib.suppress(BaseException):
237
+ task.exception()
238
+
239
+ def inflight(self, account_id: Hashable) -> asyncio.Task[AccountRefreshResult] | None:
240
+ """Return the running refresh task registered under ``account_id``, if any."""
241
+ task = self._inflight.get(account_id)
242
+ if task is None or task.done():
243
+ return None
244
+ return task
245
+
246
+ def clear(self) -> None:
247
+ self._inflight.clear()
248
+
249
+ async def cancel_all(self) -> None:
250
+ async with self._lock:
251
+ tasks = list(self._inflight.values())
252
+ self._inflight.clear()
253
+ for task in tasks:
254
+ task.cancel()
255
+ if not tasks:
256
+ return
257
+ with contextlib.suppress(BaseException):
258
+ await asyncio.gather(*tasks, return_exceptions=True)
259
+
260
+
261
+ _USAGE_REFRESH_SINGLEFLIGHT = _UsageRefreshSingleflight()
262
+
263
+
264
+ def _usage_refresh_singleflight_key(
265
+ account_id: str,
266
+ *,
267
+ own_singleflight_session: bool = False,
268
+ ) -> str | tuple[str, str]:
269
+ return ("owned-session", account_id) if own_singleflight_session else account_id
270
+
271
+
272
+ class UsageUpdater:
273
+ def __init__(
274
+ self,
275
+ usage_repo: UsageRepositoryPort,
276
+ accounts_repo: AccountsRepositoryPort | None = None,
277
+ additional_usage_repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository | None = None,
278
+ *,
279
+ auth_manager: AuthManager | None = None,
280
+ ) -> None:
281
+ self._usage_repo = usage_repo
282
+ self._accounts_repo = accounts_repo
283
+ self._additional_usage_repo = additional_usage_repo
284
+ self._encryptor = TokenEncryptor()
285
+ self._auth_manager = (
286
+ auth_manager if auth_manager is not None else AuthManager(accounts_repo) if accounts_repo else None
287
+ )
288
+
289
+ async def refresh_accounts(
290
+ self,
291
+ accounts: list[Account],
292
+ latest_usage: Mapping[str, UsageHistory],
293
+ *,
294
+ own_singleflight_sessions: bool = False,
295
+ join_existing: bool | None = None,
296
+ ) -> bool:
297
+ """Refresh usage for all accounts. Returns True if usage rows were written.
298
+
299
+ ``own_singleflight_sessions`` makes each detached singleflight refresh
300
+ acquire and release its own DB session instead of using this updater's
301
+ caller-bound repositories. ``join_existing`` controls whether a caller
302
+ joins an in-flight refresh for the same key (deduplication) or waits
303
+ and forces a fresh one; it defaults to the historical coupling
304
+ ``not own_singleflight_sessions`` so existing callers keep their
305
+ semantics.
306
+ """
307
+ if join_existing is None:
308
+ join_existing = not own_singleflight_sessions
309
+
310
+ refreshed = False
311
+ now = utcnow()
312
+ interval = USAGE_REFRESH_INTERVAL_SECONDS
313
+ _prune_usage_refresh_auth_cooldowns()
314
+ for account in accounts:
315
+ if account.status in (AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED):
316
+ continue
317
+ if _is_usage_refresh_in_cooldown(account.id):
318
+ continue
319
+ latest = await self._freshness_usage_entry(account, latest_usage.get(account.id))
320
+ bypass_freshness = _quota_recovery_should_bypass_freshness(account, latest=latest)
321
+ if not bypass_freshness and await self._usage_is_fresh_with_siblings(
322
+ account,
323
+ latest,
324
+ now=now,
325
+ interval_seconds=interval,
326
+ ):
327
+ continue
328
+ # Additional-only accounts have no main UsageHistory entry.
329
+ # Check DB-backed freshness (works across workers/restarts)
330
+ # with process-local cache as a fast path.
331
+ # NOTE: When a successful fetch returns empty additional data
332
+ # (all rows deleted), the DB has no timestamp to consult.
333
+ # Cross-worker may re-fetch; process-local cache (line ~138)
334
+ # prevents redundant calls within the same worker.
335
+ if latest is None:
336
+ last_ok = _last_successful_refresh.get(account.id)
337
+ if not bypass_freshness and last_ok and (now - last_ok).total_seconds() < interval:
338
+ continue
339
+ if self._additional_usage_repo is not None:
340
+ additional_fresh_at = await self._additional_usage_repo.latest_recorded_at_for_account(
341
+ account.id,
342
+ )
343
+ if (
344
+ not bypass_freshness
345
+ and additional_fresh_at
346
+ and (now - additional_fresh_at).total_seconds() < interval
347
+ ):
348
+ _last_successful_refresh[account.id] = additional_fresh_at
349
+ continue
350
+ # NOTE: AsyncSession is not safe for concurrent use. Run sequentially
351
+ # within the request-scoped session to avoid PK collisions and
352
+ # flush-time warnings (SAWarning: Session.add during flush).
353
+ try:
354
+ if own_singleflight_sessions:
355
+
356
+ async def refresh_factory(account_id: str = account.id) -> AccountRefreshResult:
357
+ return await self._refresh_account_if_stale_with_owned_session(
358
+ account_id,
359
+ interval_seconds=interval,
360
+ )
361
+
362
+ else:
363
+
364
+ async def refresh_factory(account: Account = account) -> AccountRefreshResult:
365
+ return await self._refresh_account_if_stale(
366
+ account,
367
+ usage_account_id=account.chatgpt_account_id,
368
+ interval_seconds=interval,
369
+ )
370
+
371
+ result = await _USAGE_REFRESH_SINGLEFLIGHT.run(
372
+ _usage_refresh_singleflight_key(
373
+ account.id,
374
+ own_singleflight_session=own_singleflight_sessions,
375
+ ),
376
+ refresh_factory,
377
+ join_existing=join_existing,
378
+ )
379
+ if join_existing:
380
+ await self._sync_account_from_repo(account)
381
+ refreshed = refreshed or result.usage_written
382
+ # Only cache when the upstream fetch actually succeeded.
383
+ # Transient errors (401 retry failure, 5xx, etc.) must not
384
+ # suppress retries within the interval.
385
+ if result.fetch_succeeded:
386
+ _last_successful_refresh[account.id] = now
387
+ _clear_usage_refresh_auth_cooldown(account.id)
388
+ except Exception as exc:
389
+ logger.warning(
390
+ "Usage refresh failed account_id=%s request_id=%s error=%s",
391
+ account.id,
392
+ get_request_id(),
393
+ exc,
394
+ exc_info=True,
395
+ )
396
+ # swallow per-account failures so the whole refresh loop keeps going
397
+ continue
398
+ return refreshed
399
+
400
+ async def force_refresh(
401
+ self,
402
+ account: Account,
403
+ *,
404
+ access_token_override: str | None = None,
405
+ ) -> bool:
406
+ """Refresh one account regardless of cached/fresh usage rows."""
407
+ result = await self.force_refresh_result(account, access_token_override=access_token_override)
408
+ return result.usage_written
409
+
410
+ async def force_refresh_result(
411
+ self,
412
+ account: Account,
413
+ *,
414
+ access_token_override: str | None = None,
415
+ ) -> AccountRefreshResult:
416
+ """Refresh one account and expose whether the upstream fetch completed."""
417
+ if account.status in (AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED):
418
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
419
+ try:
420
+ result = await _USAGE_REFRESH_SINGLEFLIGHT.run(
421
+ account.id,
422
+ lambda: self._refresh_account(
423
+ account,
424
+ usage_account_id=account.chatgpt_account_id,
425
+ access_token_override=access_token_override,
426
+ ),
427
+ join_existing=False,
428
+ )
429
+ await self._sync_account_from_repo(account)
430
+ if result.fetch_succeeded:
431
+ _last_successful_refresh[account.id] = utcnow()
432
+ _clear_usage_refresh_auth_cooldown(account.id)
433
+ return result
434
+ except Exception as exc:
435
+ logger.warning(
436
+ "Forced usage refresh failed account_id=%s request_id=%s error=%s",
437
+ account.id,
438
+ get_request_id(),
439
+ exc,
440
+ exc_info=True,
441
+ )
442
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
443
+
444
+ @staticmethod
445
+ def request_refresh(account_id: str) -> Coroutine[Any, Any, None] | None:
446
+ """Return an immediate background refresh for ``account_id``, or ``None`` when suppressed.
447
+
448
+ The caller schedules the coroutine as a tracked task. It loads the
449
+ account from a fresh background row (never a caller's ``Account``),
450
+ bypasses freshness, and joins an in-flight refresh of the account on
451
+ either singleflight lane (the scheduler's caller-session key or the
452
+ owned-session key) instead of fetching again. Repeats within
453
+ ``_REQUEST_REFRESH_DEBOUNCE_SECONDS`` are dropped, as are accounts in
454
+ auth cooldown.
455
+ """
456
+ if _is_usage_refresh_in_cooldown(account_id):
457
+ return None
458
+ now = time.monotonic()
459
+ deadline = _usage_request_refresh_deadlines.get(account_id)
460
+ if deadline is not None and deadline > now:
461
+ return None
462
+ _usage_request_refresh_deadlines[account_id] = now + _REQUEST_REFRESH_DEBOUNCE_SECONDS
463
+ return _run_requested_refresh(account_id)
464
+
465
+ async def _refresh_account_if_stale(
466
+ self,
467
+ account: Account,
468
+ *,
469
+ usage_account_id: str | None,
470
+ interval_seconds: int,
471
+ ) -> AccountRefreshResult:
472
+ primary_latest = await self._usage_repo.latest_entry_for_account(account.id, window="primary")
473
+ latest = await self._freshness_usage_entry(account, primary_latest)
474
+ if not _quota_recovery_should_bypass_freshness(account, latest=latest):
475
+ if await self._usage_is_fresh_with_siblings(
476
+ account,
477
+ latest,
478
+ now=utcnow(),
479
+ interval_seconds=interval_seconds,
480
+ ):
481
+ return AccountRefreshResult(usage_written=False)
482
+ return await self._refresh_account(
483
+ account,
484
+ usage_account_id=usage_account_id,
485
+ )
486
+
487
+ async def _additional_usage_is_stale(self, account_id: str, *, now: datetime, interval_seconds: int) -> bool:
488
+ # The upstream fetch is the only path that syncs additional
489
+ # (per-model) rate limits; live main-window rows must not keep an
490
+ # account "fresh" while its additional rows age past the interval,
491
+ # or gated-model routing starves on additional_quota_data_unavailable.
492
+ if self._additional_usage_repo is None:
493
+ return False
494
+ latest_at = await self._additional_usage_repo.latest_recorded_at_for_account(account_id)
495
+ if latest_at is None:
496
+ # No additional rows ever fetched: live rows alone must not
497
+ # suppress the fetch, or a first gated-model use would never be
498
+ # discovered. Non-gated accounts simply keep the pre-live poll
499
+ # cadence.
500
+ return True
501
+ return (now - latest_at).total_seconds() >= interval_seconds
502
+
503
+ async def _freshness_usage_entry(self, account: Account, latest: UsageHistory | None) -> UsageHistory | None:
504
+ if latest is not None:
505
+ return latest
506
+ if usage_core.capacity_for_plan(account.plan_type, "monthly") is None:
507
+ return None
508
+ return await self._usage_repo.latest_entry_for_account(account.id, window="monthly")
509
+
510
+ async def _usage_is_fresh_with_siblings(
511
+ self,
512
+ account: Account,
513
+ latest: UsageHistory | None,
514
+ *,
515
+ now: datetime,
516
+ interval_seconds: int,
517
+ ) -> bool:
518
+ if _latest_usage_is_fresh(latest, now=now, interval_seconds=interval_seconds):
519
+ return not await self._additional_usage_is_stale(account.id, now=now, interval_seconds=interval_seconds)
520
+ # A stale (or elapsed-reset) newest row of one window must not
521
+ # permanently defeat freshness once upstream stops reporting that
522
+ # window: a strictly newer sibling-window row proves a later fetch
523
+ # happened without rewriting the stale window, so the newest row's
524
+ # own freshness governs the account instead. When no primary-slot
525
+ # row exists at all (upstream omitted the short window from the
526
+ # first fetch), any sibling row is that proof.
527
+ newest = latest
528
+ for window in _MAIN_USAGE_WINDOWS:
529
+ if latest is not None and window == latest.window:
530
+ continue
531
+ if window == "monthly" and usage_core.capacity_for_plan(account.plan_type, "monthly") is None:
532
+ # A lingering monthly row from a former plan (e.g. after a
533
+ # free-to-paid upgrade) is not applicable usage and must not
534
+ # suppress refreshes.
535
+ continue
536
+ sibling = await self._usage_repo.latest_entry_for_account(account.id, window=window)
537
+ if sibling is not None and (newest is None or sibling.recorded_at > newest.recorded_at):
538
+ newest = sibling
539
+ if newest is None or newest is latest:
540
+ return False
541
+ if (
542
+ latest is not None
543
+ and (newest.recorded_at - latest.recorded_at).total_seconds() <= usage_core.SIBLING_FETCH_MARGIN_SECONDS
544
+ ):
545
+ return False
546
+ if not _latest_usage_is_fresh(newest, now=now, interval_seconds=interval_seconds):
547
+ return False
548
+ return not await self._additional_usage_is_stale(account.id, now=now, interval_seconds=interval_seconds)
549
+
550
+ @staticmethod
551
+ async def _load_owned_session_updater(account_id: str) -> tuple[UsageUpdater, Account] | None:
552
+ """Load a fresh eligible row for ``account_id`` with repositories that own their sessions."""
553
+
554
+ @contextlib.asynccontextmanager
555
+ async def refresh_repo_factory():
556
+ yield BackgroundAccountsRepository()
557
+
558
+ accounts_repo = BackgroundAccountsRepository()
559
+ account = await accounts_repo.get_by_id(account_id)
560
+ if account is None:
561
+ return None
562
+ if account.status in (AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED):
563
+ return None
564
+ updater = UsageUpdater(
565
+ BackgroundUsageRepository(),
566
+ accounts_repo,
567
+ BackgroundAdditionalUsageRepository(),
568
+ auth_manager=AuthManager(accounts_repo, refresh_repo_factory=refresh_repo_factory),
569
+ )
570
+ return updater, account
571
+
572
+ async def _refresh_account_if_stale_with_owned_session(
573
+ self,
574
+ account_id: str,
575
+ *,
576
+ interval_seconds: int,
577
+ ) -> AccountRefreshResult:
578
+ loaded = await self._load_owned_session_updater(account_id)
579
+ if loaded is None:
580
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
581
+ updater, account = loaded
582
+ return await updater._refresh_account_if_stale(
583
+ account,
584
+ usage_account_id=account.chatgpt_account_id,
585
+ interval_seconds=interval_seconds,
586
+ )
587
+
588
+ async def _refresh_account(
589
+ self,
590
+ account: Account,
591
+ *,
592
+ usage_account_id: str | None,
593
+ access_token_override: str | None = None,
594
+ ) -> AccountRefreshResult:
595
+ access_token = access_token_override or self._encryptor.decrypt(account.access_token_encrypted)
596
+ payload: UsagePayload | None = None
597
+ try:
598
+ route = await _resolve_upstream_route_for_account(account, operation="usage_refresh")
599
+ payload = await fetch_usage(
600
+ access_token=access_token,
601
+ account_id=usage_account_id,
602
+ route=route,
603
+ allow_direct_egress=route is None,
604
+ )
605
+ except UpstreamProxyRouteError as exc:
606
+ logger.warning(
607
+ "Usage refresh upstream proxy route unavailable account_id=%s reason=%s",
608
+ account.id,
609
+ exc.reason,
610
+ )
611
+ _mark_usage_refresh_auth_cooldown(account.id, 0)
612
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
613
+ except UsageFetchError as exc:
614
+ if _should_deactivate_for_usage_error(exc):
615
+ await self._deactivate_for_client_error(account, exc)
616
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
617
+ if access_token_override is not None:
618
+ _mark_usage_refresh_auth_cooldown(account.id, exc.status_code)
619
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
620
+ if exc.status_code != 401 or not self._auth_manager:
621
+ _mark_usage_refresh_auth_cooldown(account.id, exc.status_code)
622
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
623
+ try:
624
+ account = await self._auth_manager.ensure_fresh(account, force=True)
625
+ except RefreshError:
626
+ _mark_usage_refresh_auth_cooldown(account.id, exc.status_code)
627
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
628
+ access_token = self._encryptor.decrypt(account.access_token_encrypted)
629
+ try:
630
+ route = await _resolve_upstream_route_for_account(account, operation="usage_refresh")
631
+ payload = await fetch_usage(
632
+ access_token=access_token,
633
+ account_id=usage_account_id,
634
+ route=route,
635
+ allow_direct_egress=route is None,
636
+ )
637
+ except UpstreamProxyRouteError as route_exc:
638
+ logger.warning(
639
+ "Usage refresh retry upstream proxy route unavailable account_id=%s reason=%s",
640
+ account.id,
641
+ route_exc.reason,
642
+ )
643
+ _mark_usage_refresh_auth_cooldown(account.id, 0)
644
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
645
+ except UsageFetchError as retry_exc:
646
+ if _should_deactivate_for_usage_error(retry_exc):
647
+ await self._deactivate_for_client_error(account, retry_exc)
648
+ else:
649
+ _mark_usage_refresh_auth_cooldown(account.id, retry_exc.status_code)
650
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
651
+
652
+ if payload is None:
653
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
654
+
655
+ if await _payload_mismatches_account_slot(account, payload):
656
+ logger.warning(
657
+ "Usage refresh payload identity mismatch; skipping account mutation "
658
+ "account_id=%s stored_workspace_id=%s payload_workspace_id=%s stored_plan_type=%s "
659
+ "payload_plan_type=%s stored_seat_type=%s payload_seat_type=%s request_id=%s",
660
+ account.id,
661
+ account.workspace_id,
662
+ payload.workspace_id,
663
+ account.plan_type,
664
+ payload.plan_type,
665
+ account.seat_type,
666
+ payload.seat_type,
667
+ get_request_id(),
668
+ )
669
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
670
+
671
+ identity_matches_slot = await self._sync_identity_metadata(account, payload)
672
+ if not identity_matches_slot:
673
+ logger.warning(
674
+ "Usage refresh payload reported a workspace slot owned by another account; "
675
+ "skipping account usage mutation account_id=%s payload_workspace_id=%s request_id=%s",
676
+ account.id,
677
+ payload.workspace_id,
678
+ get_request_id(),
679
+ )
680
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
681
+
682
+ now_epoch = _now_epoch()
683
+ if self._additional_usage_repo is not None:
684
+ if payload.additional_rate_limits:
685
+ merged_limits = _merge_additional_rate_limits(
686
+ payload.additional_rate_limits,
687
+ account_id=account.id,
688
+ now_epoch=now_epoch,
689
+ )
690
+ current_entries: set[tuple[str, str]] = set()
691
+ for quota_key, windows in merged_limits.items():
692
+ for window, merged_window in windows.items():
693
+ current_entries.add((quota_key, window))
694
+ await _add_additional_usage_entry(
695
+ self._additional_usage_repo,
696
+ account_id=account.id,
697
+ limit_name=merged_window.limit_name,
698
+ metered_feature=merged_window.metered_feature,
699
+ quota_key=quota_key,
700
+ window=window,
701
+ used_percent=merged_window.used_percent,
702
+ reset_at=merged_window.reset_at,
703
+ window_minutes=merged_window.window_minutes,
704
+ )
705
+ current_quota_keys = {name for name, _ in current_entries}
706
+ existing_quota_keys = await _list_additional_usage_quota_keys(
707
+ self._additional_usage_repo,
708
+ account_ids=[account.id],
709
+ )
710
+ for stale_key in existing_quota_keys:
711
+ if stale_key not in current_quota_keys:
712
+ await _delete_additional_usage_quota_key(
713
+ self._additional_usage_repo,
714
+ account.id,
715
+ stale_key,
716
+ )
717
+ continue
718
+ for window in ("primary", "secondary"):
719
+ if (stale_key, window) not in current_entries:
720
+ await _delete_additional_usage_quota_key_window(
721
+ self._additional_usage_repo,
722
+ account.id,
723
+ stale_key,
724
+ window,
725
+ )
726
+ elif payload.additional_rate_limits is not None:
727
+ await self._additional_usage_repo.delete_for_account(account.id)
728
+
729
+ rate_limit = payload.rate_limit
730
+ if rate_limit is None:
731
+ additional_synced = self._additional_usage_repo is not None and payload.additional_rate_limits is not None
732
+ return AccountRefreshResult(usage_written=additional_synced)
733
+ # Treat both None and empty rate_limit (both windows absent) as
734
+ # additional-only to avoid falling through to window processing.
735
+ normalized_windows = usage_core.normalize_rate_limit_windows(
736
+ rate_limit.primary_window,
737
+ rate_limit.secondary_window,
738
+ )
739
+ primary = normalized_windows.primary
740
+ secondary = normalized_windows.secondary
741
+ monthly = normalized_windows.monthly
742
+ if primary is None and secondary is None:
743
+ if monthly is None:
744
+ additional_synced = (
745
+ self._additional_usage_repo is not None and payload.additional_rate_limits is not None
746
+ )
747
+ return AccountRefreshResult(usage_written=additional_synced)
748
+ if primary is None and secondary is None and monthly is None:
749
+ additional_synced = self._additional_usage_repo is not None and payload.additional_rate_limits is not None
750
+ return AccountRefreshResult(usage_written=additional_synced)
751
+ credits_has, credits_unlimited, credits_balance = _credits_snapshot(payload)
752
+ snapshot_windows: list[UsageWindowWrite] = []
753
+
754
+ if primary and primary.used_percent is not None:
755
+ snapshot_windows.append(
756
+ UsageWindowWrite(
757
+ window="primary",
758
+ used_percent=float(primary.used_percent),
759
+ reset_at=_reset_at(primary.reset_at, primary.reset_after_seconds, now_epoch),
760
+ window_minutes=_window_minutes(primary.limit_window_seconds),
761
+ credits_has=credits_has,
762
+ credits_unlimited=credits_unlimited,
763
+ credits_balance=credits_balance,
764
+ )
765
+ )
766
+
767
+ if secondary and secondary.used_percent is not None:
768
+ snapshot_windows.append(
769
+ UsageWindowWrite(
770
+ window="secondary",
771
+ used_percent=float(secondary.used_percent),
772
+ reset_at=_reset_at(secondary.reset_at, secondary.reset_after_seconds, now_epoch),
773
+ window_minutes=_window_minutes(secondary.limit_window_seconds),
774
+ )
775
+ )
776
+
777
+ if monthly and monthly.used_percent is not None:
778
+ snapshot_windows.append(
779
+ UsageWindowWrite(
780
+ window="monthly",
781
+ used_percent=float(monthly.used_percent),
782
+ reset_at=_reset_at(monthly.reset_at, monthly.reset_after_seconds, now_epoch),
783
+ window_minutes=_window_minutes(monthly.limit_window_seconds),
784
+ credits_has=credits_has,
785
+ credits_unlimited=credits_unlimited,
786
+ credits_balance=credits_balance,
787
+ )
788
+ )
789
+
790
+ entries = await self._usage_repo.add_account_snapshot(
791
+ account.id,
792
+ snapshot_windows,
793
+ )
794
+ usage_written = any(_usage_entry_written(entry) for entry in entries)
795
+ await self._recover_quota_status_from_usage(account, primary=primary, secondary=secondary, monthly=monthly)
796
+ return AccountRefreshResult(usage_written=usage_written)
797
+
798
+ async def _deactivate_for_client_error(self, account: Account, exc: UsageFetchError) -> None:
799
+ if not self._auth_manager:
800
+ return
801
+ reason = f"Usage API error: HTTP {exc.status_code} - {exc.message}"
802
+ status = (
803
+ account_status_for_permanent_failure(exc.code)
804
+ if exc.code in PERMANENT_FAILURE_CODES
805
+ else AccountStatus.DEACTIVATED
806
+ )
807
+ logger.warning(
808
+ "Marking account unavailable due to client error account_id=%s account_status=%s status=%s "
809
+ "message=%s request_id=%s",
810
+ account.id,
811
+ status.value,
812
+ exc.status_code,
813
+ exc.message,
814
+ get_request_id(),
815
+ )
816
+ await self._auth_manager._repo.update_status(account.id, status, reason)
817
+ account.status = status
818
+ account.deactivation_reason = reason
819
+ if status == AccountStatus.DEACTIVATED:
820
+ mark_account_routing_unavailable(account.id)
821
+ get_account_selection_cache().invalidate()
822
+
823
+ async def _sync_identity_metadata(self, account: Account, payload: UsagePayload) -> bool:
824
+ next_plan_type = coerce_account_plan_type(payload.plan_type, account.plan_type or "free")
825
+ payload_workspace_id = _clean_optional(payload.workspace_id)
826
+ next_workspace_id = payload_workspace_id or account.workspace_id
827
+ next_workspace_label = _clean_optional(payload.workspace_label) or account.workspace_label
828
+ next_seat_type = _clean_optional(payload.seat_type) or account.seat_type
829
+ if self._auth_manager and payload_workspace_id and not account.workspace_id:
830
+ slot_taken = await self._auth_manager._repo.workspace_slot_taken(
831
+ account_id=account.id,
832
+ email=account.email,
833
+ chatgpt_account_id=account.chatgpt_account_id,
834
+ workspace_id=payload_workspace_id,
835
+ )
836
+ if slot_taken:
837
+ logger.warning(
838
+ "Usage payload reported workspace_id=%s for legacy account_id=%s, but that slot "
839
+ "is already owned by another account; skipping usage payload",
840
+ payload_workspace_id,
841
+ account.id,
842
+ )
843
+ return False
844
+ if (
845
+ next_plan_type == account.plan_type
846
+ and next_workspace_id == account.workspace_id
847
+ and next_workspace_label == account.workspace_label
848
+ and next_seat_type == account.seat_type
849
+ ):
850
+ return True
851
+
852
+ account.plan_type = next_plan_type
853
+ account.workspace_id = next_workspace_id
854
+ account.workspace_label = next_workspace_label
855
+ account.seat_type = next_seat_type
856
+ if not self._auth_manager:
857
+ return True
858
+
859
+ # Identity/plan/workspace sync only. This runs against a stale in-memory
860
+ # ``account`` snapshot taken well before the write, so it MUST route
861
+ # through the metadata-only writer, which structurally cannot touch
862
+ # token ciphertext. Persisting token material from this snapshot would
863
+ # clobber a peer replica's concurrent refresh-token rotation.
864
+ await self._auth_manager._repo.update_account_metadata(
865
+ account.id,
866
+ plan_type=account.plan_type,
867
+ email=account.email,
868
+ chatgpt_account_id=account.chatgpt_account_id,
869
+ workspace_id=account.workspace_id,
870
+ workspace_label=account.workspace_label,
871
+ seat_type=account.seat_type,
872
+ )
873
+ return True
874
+
875
+ async def _recover_quota_status_from_usage(
876
+ self,
877
+ account: Account,
878
+ *,
879
+ primary: UsageWindow | None,
880
+ secondary: UsageWindow | None,
881
+ monthly: UsageWindow | None = None,
882
+ ) -> None:
883
+ if not self._auth_manager:
884
+ return
885
+ if account.status == AccountStatus.RATE_LIMITED:
886
+ if account.blocked_at is not None:
887
+ # An account marked RATE_LIMITED by an actual 429 always
888
+ # carries a blocked_at marker. Honor the persisted cooldown
889
+ # deadline (Retry-After hint, upstream reset metadata, or the
890
+ # backoff floor) so the periodic usage refresh cannot flip the
891
+ # account back to ACTIVE — erasing reset_at that peer replicas
892
+ # rely on — while the upstream cooldown is still running.
893
+ # Fresh usage showing quota is not evidence the 429 window
894
+ # ended: throttles are not always quota-based. Rows without
895
+ # blocked_at are stale window-derived markings and keep the
896
+ # fresh-usage recovery below.
897
+ now = time.time()
898
+ cooldown_deadline = plausible_rate_limit_reset_at(account.reset_at, now=now) or (
899
+ float(account.blocked_at) + RATE_LIMITED_MIN_COOLDOWN_SECONDS
900
+ )
901
+ if now < cooldown_deadline:
902
+ return
903
+ long_window = monthly or secondary
904
+ if primary is None and monthly is None:
905
+ return
906
+ if primary is not None and not _window_has_available_quota(primary):
907
+ return
908
+ if primary is None and (long_window is None or not _window_has_available_quota(long_window)):
909
+ return
910
+ if long_window is not None and not _window_has_available_quota(long_window):
911
+ return
912
+ target_status = AccountStatus.ACTIVE
913
+ target_reset_at = None
914
+ expected_status = AccountStatus.RATE_LIMITED
915
+ elif account.status == AccountStatus.QUOTA_EXCEEDED:
916
+ if account.blocked_at is not None and time.time() < account.blocked_at + QUOTA_EXCEEDED_COOLDOWN_SECONDS:
917
+ return
918
+ long_window = monthly or secondary
919
+ windows = [window for window in (primary, long_window) if window is not None]
920
+ if long_window is None or not _window_has_available_quota(long_window):
921
+ return
922
+ if primary is not None and _window_is_exhausted(primary):
923
+ target_status = AccountStatus.RATE_LIMITED
924
+ target_reset_at = _reset_at(primary.reset_at, primary.reset_after_seconds, _now_epoch())
925
+ else:
926
+ if any(_window_is_exhausted(window) for window in windows):
927
+ return
928
+ target_status = AccountStatus.ACTIVE
929
+ target_reset_at = None
930
+ if not any(_window_has_available_quota(window) for window in windows):
931
+ return
932
+ expected_status = AccountStatus.QUOTA_EXCEEDED
933
+ else:
934
+ return
935
+
936
+ repo = cast(AccountsRepositoryWithStatusComparePort, self._auth_manager._repo)
937
+ updated = await repo.update_status_if_current(
938
+ account.id,
939
+ target_status,
940
+ None,
941
+ target_reset_at,
942
+ blocked_at=None,
943
+ expected_status=expected_status,
944
+ expected_deactivation_reason=account.deactivation_reason,
945
+ expected_reset_at=account.reset_at,
946
+ expected_blocked_at=account.blocked_at,
947
+ )
948
+ if not updated:
949
+ await self._sync_account_from_repo(account)
950
+ return
951
+ account.status = target_status
952
+ account.deactivation_reason = None
953
+ account.reset_at = target_reset_at
954
+ account.blocked_at = None
955
+
956
+ async def _sync_account_from_repo(self, account: Account) -> None:
957
+ if not self._accounts_repo:
958
+ return
959
+ # Joined owned-session refreshes run in a different session. A plain
960
+ # get_by_id() can return the caller session's stale identity-map row.
961
+ stored = await self._accounts_repo.get_by_id_fresh(account.id)
962
+ if stored is None:
963
+ return
964
+ account.chatgpt_account_id = stored.chatgpt_account_id
965
+ account.email = stored.email
966
+ account.workspace_id = stored.workspace_id
967
+ account.workspace_label = stored.workspace_label
968
+ account.seat_type = stored.seat_type
969
+ account.plan_type = stored.plan_type
970
+ account.access_token_encrypted = stored.access_token_encrypted
971
+ account.refresh_token_encrypted = stored.refresh_token_encrypted
972
+ account.id_token_encrypted = stored.id_token_encrypted
973
+ account.last_refresh = stored.last_refresh
974
+ account.status = stored.status
975
+ account.deactivation_reason = stored.deactivation_reason
976
+ account.reset_at = stored.reset_at
977
+ account.blocked_at = stored.blocked_at
978
+
979
+
980
+ async def _run_requested_refresh(account_id: str) -> None:
981
+ async def refresh_factory() -> AccountRefreshResult:
982
+ loaded = await UsageUpdater._load_owned_session_updater(account_id)
983
+ if loaded is None:
984
+ return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
985
+ updater, account = loaded
986
+ return await updater._refresh_account(account, usage_account_id=account.chatgpt_account_id)
987
+
988
+ try:
989
+ # Two singleflight lanes exist per account: the scheduler (and
990
+ # ``force_refresh``) run on the bare ``account_id`` key with
991
+ # caller-bound sessions, while the rate-limit payload and fleet paths
992
+ # run on the owned-session key. A request must not add a third
993
+ # concurrent fetch, so it joins a scheduler refresh already in flight
994
+ # and otherwise runs on the owned-session key, where ``join_existing``
995
+ # never queues a successor fetch.
996
+ scheduler_refresh = _USAGE_REFRESH_SINGLEFLIGHT.inflight(_usage_refresh_singleflight_key(account_id))
997
+ if scheduler_refresh is not None:
998
+ result = await wait_on_shared_future(scheduler_refresh)
999
+ else:
1000
+ result = await _USAGE_REFRESH_SINGLEFLIGHT.run(
1001
+ _usage_refresh_singleflight_key(account_id, own_singleflight_session=True),
1002
+ refresh_factory,
1003
+ join_existing=True,
1004
+ )
1005
+ except Exception as exc:
1006
+ logger.warning(
1007
+ "Requested usage refresh failed account_id=%s request_id=%s error=%s",
1008
+ account_id,
1009
+ get_request_id(),
1010
+ exc,
1011
+ exc_info=True,
1012
+ )
1013
+ return
1014
+ if result.fetch_succeeded:
1015
+ _last_successful_refresh[account_id] = utcnow()
1016
+ _clear_usage_refresh_auth_cooldown(account_id)
1017
+ if result.usage_written:
1018
+ # ``mark_rate_limit`` invalidated the selection cache before this row
1019
+ # existed, so a selection in between repopulated it without usage
1020
+ # evidence. Drop that entry now instead of letting the >= 100 % row
1021
+ # wait out the cache TTL before the pool reports exhaustion.
1022
+ get_account_selection_cache().invalidate()
1023
+
1024
+
1025
+ def build_background_usage_updater() -> UsageUpdater:
1026
+ accounts_repo = BackgroundAccountsRepository()
1027
+ return UsageUpdater(
1028
+ BackgroundUsageRepository(),
1029
+ accounts_repo=accounts_repo,
1030
+ additional_usage_repo=BackgroundAdditionalUsageRepository(),
1031
+ auth_manager=AuthManager(accounts_repo),
1032
+ )
1033
+
1034
+
1035
+ def _credits_snapshot(payload: UsagePayload) -> tuple[bool | None, bool | None, float | None]:
1036
+ credits = payload.credits
1037
+ if credits is None:
1038
+ return None, None, None
1039
+ credits_has = credits.has_credits
1040
+ credits_unlimited = credits.unlimited
1041
+ balance_value = credits.balance
1042
+ return credits_has, credits_unlimited, _parse_credits_balance(balance_value)
1043
+
1044
+
1045
+ async def _payload_mismatches_account_slot(account: Account, payload: UsagePayload) -> bool:
1046
+ payload_workspace_id = _clean_optional(payload.workspace_id)
1047
+ if account.workspace_id and payload_workspace_id and account.workspace_id != payload_workspace_id:
1048
+ # The payload reports a different workspace slot than the one this
1049
+ # account is bound to; refuse to write another workspace's usage/plan.
1050
+ # This guard is unconditional: repetition never makes a conflicting
1051
+ # workspace identity trustworthy.
1052
+ return True
1053
+ if not payload_workspace_id and payload.plan_type:
1054
+ payload_plan_type = coerce_account_plan_type(payload.plan_type, account.plan_type or "free")
1055
+ normalized_payload_plan_type = normalize_account_plan_type(payload.plan_type)
1056
+ stored_plan_type = coerce_account_plan_type(account.plan_type, "free")
1057
+ recognized_paid_plans = ACCOUNT_PLAN_TYPES - {"free"}
1058
+ # A transition from free into a recognized paid plan (e.g. Free -> Plus)
1059
+ # or between paid plans (e.g. Plus -> Pro) is a legitimate plan change,
1060
+ # not an identity mismatch: the usage payload carries no independent
1061
+ # account identifier and is fetched per-account token, so plan_type alone
1062
+ # cannot establish identity. A workspace-less payload that instead
1063
+ # introduces "free" or an unrecognized plan stays untrusted -- the
1064
+ # signature of a degraded or wrong-identity usage response that must not
1065
+ # silently rewrite the stored plan.
1066
+ if payload_plan_type != stored_plan_type and not (
1067
+ stored_plan_type == "unknown"
1068
+ and normalized_payload_plan_type in recognized_paid_plans
1069
+ or (
1070
+ not account.workspace_id
1071
+ and stored_plan_type in ACCOUNT_PLAN_TYPES
1072
+ and payload_plan_type in recognized_paid_plans
1073
+ )
1074
+ ):
1075
+ # A subscription that expires reports "free" every cycle from that
1076
+ # account's own token, so an agreeing repeat observation is no
1077
+ # longer the single-sample degraded signature above. Persist the
1078
+ # downgrade only once it has been confirmed (issue #1456).
1079
+ if await _free_plan_downgrade_is_confirmed(
1080
+ account,
1081
+ stored_plan_type=stored_plan_type,
1082
+ normalized_payload_plan_type=normalized_payload_plan_type,
1083
+ ):
1084
+ return False
1085
+ return True
1086
+ if normalize_account_plan_type(payload.plan_type) in (ACCOUNT_PLAN_TYPES - {"free"}):
1087
+ # The account reports a recognized paid plan, which is positive
1088
+ # evidence that it is still paid, so any pending downgrade evidence
1089
+ # is discarded. An unrecognized value is absence of evidence and
1090
+ # deliberately does not reach here.
1091
+ await _clear_workspace_less_free_plan_observations(account.id)
1092
+ return False
1093
+
1094
+
1095
+ async def _free_plan_downgrade_is_confirmed(
1096
+ account: Account,
1097
+ *,
1098
+ stored_plan_type: str,
1099
+ normalized_payload_plan_type: str | None,
1100
+ ) -> bool:
1101
+ """Record a workspace-less paid -> free observation and report confirmation.
1102
+
1103
+ Only a recognized ``free`` payload against a recognized paid stored plan on a
1104
+ workspace-less account is confirmable. Unrecognized plan values never
1105
+ accumulate, so a degraded response reporting garbage can never downgrade an
1106
+ account no matter how often it repeats.
1107
+ """
1108
+ if normalized_payload_plan_type != "free":
1109
+ return False
1110
+ if stored_plan_type not in (ACCOUNT_PLAN_TYPES - {"free"}):
1111
+ return False
1112
+ if account.workspace_id:
1113
+ # The account is bound to a workspace, so a payload that omits
1114
+ # ``workspace_id`` cannot establish that it describes this slot. A
1115
+ # workspace-bound seat (Team/Business/Enterprise) must not be demoted to
1116
+ # free on the strength of a payload that never names its workspace,
1117
+ # however many times it repeats.
1118
+ return False
1119
+ fingerprint = credential_fingerprint(account)
1120
+ store = _plan_downgrade_observation_store()
1121
+ # One atomic step records the observation and returns the resulting count.
1122
+ # The fingerprint digests the account's stable seat identity, not token
1123
+ # material, so routine token rotation between two observations does not
1124
+ # restart the sequence and a real expiry still converges (#1456). Credential
1125
+ # replacement (re-import or in-place reauthentication) resets pending
1126
+ # evidence at the replacement site itself -- the accounts repository
1127
+ # discards it in the same transaction that applies the fresh material --
1128
+ # while the fingerprint comparison here restarts the count for any
1129
+ # remaining path that rebinds the row's seat identity. Doing this as a read
1130
+ # followed by a write would leave an await between the two halves, letting
1131
+ # concurrent refreshes for one account lose an increment.
1132
+ observations = await store.observe(
1133
+ account.id,
1134
+ credential_fingerprint=fingerprint,
1135
+ observed_plan_type="free",
1136
+ )
1137
+ if observations < _FREE_PLAN_DOWNGRADE_CONFIRMATIONS:
1138
+ logger.info(
1139
+ "Usage refresh observed a workspace-less downgrade to free; awaiting confirmation "
1140
+ "account_id=%s stored_plan_type=%s observations=%s required=%s request_id=%s",
1141
+ account.id,
1142
+ stored_plan_type,
1143
+ observations,
1144
+ _FREE_PLAN_DOWNGRADE_CONFIRMATIONS,
1145
+ get_request_id(),
1146
+ )
1147
+ return False
1148
+ await store.clear(account.id)
1149
+ logger.info(
1150
+ "Usage refresh confirmed a workspace-less downgrade to free; persisting plan change "
1151
+ "account_id=%s stored_plan_type=%s observations=%s request_id=%s",
1152
+ account.id,
1153
+ stored_plan_type,
1154
+ observations,
1155
+ get_request_id(),
1156
+ )
1157
+ return True
1158
+
1159
+
1160
+ def _plan_downgrade_observation_store() -> PlanDowngradeObservationStorePort:
1161
+ """Resolve the shared store, falling back to process-local state.
1162
+
1163
+ The database-backed store is the process default so the observation sequence
1164
+ is coherent across replicas. When persistence is explicitly disabled (the
1165
+ DB-less unit-test harness), the fallback preserves the previous
1166
+ single-process behavior rather than losing the guard entirely.
1167
+ """
1168
+ store = get_plan_downgrade_observation_store()
1169
+ if store is not None:
1170
+ return store
1171
+ return _FALLBACK_PLAN_DOWNGRADE_OBSERVATIONS
1172
+
1173
+
1174
+ async def _clear_workspace_less_free_plan_observations(account_id: str) -> None:
1175
+ await _plan_downgrade_observation_store().clear(account_id)
1176
+
1177
+
1178
+ def _usage_entry_written(entry: UsageHistory | None) -> bool:
1179
+ return entry is not None
1180
+
1181
+
1182
+ def _window_has_available_quota(window: UsageWindow) -> bool:
1183
+ used_percent = window.used_percent
1184
+ return used_percent is not None and float(used_percent) < 100.0
1185
+
1186
+
1187
+ def _window_is_exhausted(window: UsageWindow) -> bool:
1188
+ used_percent = window.used_percent
1189
+ return used_percent is not None and float(used_percent) >= 100.0
1190
+
1191
+
1192
+ def _prefer_merged_additional_window(
1193
+ existing: _MergedAdditionalWindow,
1194
+ candidate: _MergedAdditionalWindow,
1195
+ *,
1196
+ quota_key: str,
1197
+ window: str,
1198
+ ) -> _MergedAdditionalWindow:
1199
+ if candidate.used_percent > existing.used_percent:
1200
+ logger.warning(
1201
+ "Additional usage refresh saw conflicting aliases for the same canonical quota window; "
1202
+ "keeping the higher usage sample account_quota=%s window=%s existing_limit=%s candidate_limit=%s "
1203
+ "request_id=%s",
1204
+ quota_key,
1205
+ window,
1206
+ existing.limit_name,
1207
+ candidate.limit_name,
1208
+ get_request_id(),
1209
+ )
1210
+ return candidate
1211
+ if candidate.used_percent < existing.used_percent:
1212
+ logger.warning(
1213
+ "Additional usage refresh saw conflicting aliases for the same canonical quota window; "
1214
+ "keeping the higher usage sample account_quota=%s window=%s existing_limit=%s candidate_limit=%s "
1215
+ "request_id=%s",
1216
+ quota_key,
1217
+ window,
1218
+ existing.limit_name,
1219
+ candidate.limit_name,
1220
+ get_request_id(),
1221
+ )
1222
+ return existing
1223
+ preferred = sorted(
1224
+ (existing, candidate),
1225
+ key=lambda entry: (entry.limit_name, entry.metered_feature),
1226
+ )[0]
1227
+ if preferred != existing or existing != candidate:
1228
+ logger.info(
1229
+ "Additional usage refresh coalesced duplicate aliases for canonical quota window "
1230
+ "account_quota=%s window=%s chosen_limit=%s request_id=%s",
1231
+ quota_key,
1232
+ window,
1233
+ preferred.limit_name,
1234
+ get_request_id(),
1235
+ )
1236
+ return preferred
1237
+
1238
+
1239
+ def _merge_additional_rate_limits(
1240
+ additional_rate_limits: Collection[AdditionalRateLimitPayload],
1241
+ *,
1242
+ account_id: str,
1243
+ now_epoch: int,
1244
+ ) -> dict[str, dict[str, _MergedAdditionalWindow]]:
1245
+ merged: dict[str, dict[str, _MergedAdditionalWindow]] = {}
1246
+ for additional in additional_rate_limits:
1247
+ limit_name = getattr(additional, "limit_name", None)
1248
+ metered_feature = getattr(additional, "metered_feature", None)
1249
+ quota_key = canonicalize_additional_quota_key(
1250
+ limit_name=limit_name,
1251
+ metered_feature=metered_feature,
1252
+ )
1253
+ if quota_key is None:
1254
+ logger.warning(
1255
+ "Skipping additional usage item without resolvable quota key "
1256
+ "account_id=%s limit_name=%s metered_feature=%s request_id=%s",
1257
+ account_id,
1258
+ limit_name,
1259
+ metered_feature,
1260
+ get_request_id(),
1261
+ )
1262
+ continue
1263
+ rate_limit = getattr(additional, "rate_limit", None)
1264
+ if rate_limit is None:
1265
+ continue
1266
+ for window_name, usage_window in (
1267
+ ("primary", getattr(rate_limit, "primary_window", None)),
1268
+ ("secondary", getattr(rate_limit, "secondary_window", None)),
1269
+ ):
1270
+ if usage_window is None or usage_window.used_percent is None:
1271
+ continue
1272
+ candidate = _MergedAdditionalWindow(
1273
+ limit_name=str(limit_name),
1274
+ metered_feature=str(metered_feature),
1275
+ used_percent=float(usage_window.used_percent),
1276
+ reset_at=_reset_at(usage_window.reset_at, usage_window.reset_after_seconds, now_epoch),
1277
+ window_minutes=_window_minutes(usage_window.limit_window_seconds),
1278
+ )
1279
+ windows = merged.setdefault(quota_key, {})
1280
+ existing = windows.get(window_name)
1281
+ windows[window_name] = (
1282
+ candidate
1283
+ if existing is None
1284
+ else _prefer_merged_additional_window(
1285
+ existing,
1286
+ candidate,
1287
+ quota_key=quota_key,
1288
+ window=window_name,
1289
+ )
1290
+ )
1291
+ return merged
1292
+
1293
+
1294
+ async def _add_additional_usage_entry(
1295
+ repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
1296
+ *,
1297
+ account_id: str,
1298
+ limit_name: str,
1299
+ metered_feature: str,
1300
+ quota_key: str,
1301
+ window: str,
1302
+ used_percent: float,
1303
+ reset_at: int | None,
1304
+ window_minutes: int | None,
1305
+ ) -> None:
1306
+ await repo.add_entry(
1307
+ account_id=account_id,
1308
+ limit_name=limit_name,
1309
+ metered_feature=metered_feature,
1310
+ quota_key=quota_key,
1311
+ window=window,
1312
+ used_percent=used_percent,
1313
+ reset_at=reset_at,
1314
+ window_minutes=window_minutes,
1315
+ )
1316
+
1317
+
1318
+ async def _list_additional_usage_quota_keys(
1319
+ repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
1320
+ *,
1321
+ account_ids: Collection[str] | None = None,
1322
+ ) -> list[str]:
1323
+ return await repo.list_quota_keys(account_ids=account_ids)
1324
+
1325
+
1326
+ async def _delete_additional_usage_quota_key(
1327
+ repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
1328
+ account_id: str,
1329
+ quota_key: str,
1330
+ ) -> None:
1331
+ await repo.delete_for_account_and_quota_key(account_id, quota_key)
1332
+
1333
+
1334
+ async def _delete_additional_usage_quota_key_window(
1335
+ repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
1336
+ account_id: str,
1337
+ quota_key: str,
1338
+ window: str,
1339
+ ) -> None:
1340
+ await repo.delete_for_account_quota_key_window(account_id, quota_key, window)
1341
+
1342
+
1343
+ def _latest_usage_is_fresh(
1344
+ latest: UsageHistory | None,
1345
+ *,
1346
+ now: datetime,
1347
+ interval_seconds: int,
1348
+ ) -> bool:
1349
+ if latest is None:
1350
+ return False
1351
+ if (now - latest.recorded_at).total_seconds() >= interval_seconds:
1352
+ return False
1353
+ if latest.reset_at is not None:
1354
+ now_epoch = int(now.replace(tzinfo=timezone.utc).timestamp())
1355
+ if now_epoch >= latest.reset_at:
1356
+ return False
1357
+ return True
1358
+
1359
+
1360
+ _MAIN_USAGE_WINDOWS = ("primary", "secondary", "monthly")
1361
+
1362
+
1363
+ def _quota_recovery_should_bypass_freshness(account: Account, *, latest: UsageHistory | None) -> bool:
1364
+ if _account_needs_post_reset_refresh(account, latest=latest):
1365
+ return True
1366
+ if account.status != AccountStatus.QUOTA_EXCEEDED:
1367
+ return False
1368
+ if account.blocked_at is None:
1369
+ return latest is None
1370
+ cooldown_expires_at = account.blocked_at + QUOTA_EXCEEDED_COOLDOWN_SECONDS
1371
+ if time.time() < cooldown_expires_at:
1372
+ return False
1373
+ if latest is None:
1374
+ return True
1375
+ recorded_at = latest.recorded_at
1376
+ if recorded_at.tzinfo is None:
1377
+ recorded_at = recorded_at.replace(tzinfo=timezone.utc)
1378
+ return recorded_at.timestamp() < cooldown_expires_at
1379
+
1380
+
1381
+ def _account_needs_post_reset_refresh(account: Account, *, latest: UsageHistory | None) -> bool:
1382
+ if account.status not in (AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED):
1383
+ return False
1384
+ if account.reset_at is None:
1385
+ return False
1386
+ if time.time() < account.reset_at:
1387
+ return False
1388
+ if latest is None:
1389
+ return True
1390
+ recorded_at = latest.recorded_at
1391
+ if recorded_at.tzinfo is None:
1392
+ recorded_at = recorded_at.replace(tzinfo=timezone.utc)
1393
+ return recorded_at.timestamp() < float(account.reset_at)
1394
+
1395
+
1396
+ def _parse_credits_balance(value: str | int | float | None) -> float | None:
1397
+ if value is None:
1398
+ return None
1399
+ if isinstance(value, (int, float)):
1400
+ return float(value)
1401
+ if isinstance(value, str):
1402
+ try:
1403
+ return float(value.strip())
1404
+ except ValueError:
1405
+ return None
1406
+ return None
1407
+
1408
+
1409
+ def _window_minutes(limit_seconds: int | None) -> int | None:
1410
+ if not limit_seconds or limit_seconds <= 0:
1411
+ return None
1412
+ return max(1, math.ceil(limit_seconds / 60))
1413
+
1414
+
1415
+ def _now_epoch() -> int:
1416
+ return int(utcnow().replace(tzinfo=timezone.utc).timestamp())
1417
+
1418
+
1419
+ def _reset_at(reset_at: int | None, reset_after_seconds: int | None, now_epoch: int) -> int | None:
1420
+ if reset_at is not None:
1421
+ return int(reset_at)
1422
+ if reset_after_seconds is None:
1423
+ return None
1424
+ return now_epoch + max(0, int(reset_after_seconds))
1425
+
1426
+
1427
+ # Bare HTTP status codes from the usage endpoint are ambiguous and do not prove
1428
+ # that an account is permanently unavailable. Only explicit error content may
1429
+ # trigger an account-level status change.
1430
+ _DEACTIVATING_USAGE_MESSAGE_HINTS = (
1431
+ "your openai account has been deactivated",
1432
+ "account has been deactivated",
1433
+ )
1434
+
1435
+
1436
+ def _should_deactivate_for_usage_error(exc: UsageFetchError) -> bool:
1437
+ if exc.code in PERMANENT_FAILURE_CODES:
1438
+ return True
1439
+ lowered = exc.message.lower()
1440
+ return any(hint in lowered for hint in _DEACTIVATING_USAGE_MESSAGE_HINTS)
1441
+
1442
+
1443
+ async def _resolve_upstream_route_for_account(account: Account, *, operation: str) -> ResolvedUpstreamRoute | None:
1444
+ async with get_background_session() as session:
1445
+ return await resolve_upstream_route(
1446
+ session,
1447
+ account_id=account.id,
1448
+ operation=operation,
1449
+ scope="account",
1450
+ )
1451
+
1452
+
1453
+ def _mark_usage_refresh_auth_cooldown(account_id: str, status_code: int) -> None:
1454
+ if status_code not in {401, 403}:
1455
+ return
1456
+ cooldown_seconds = _USAGE_REFRESH_AUTH_FAILURE_COOLDOWN_SECONDS
1457
+ if cooldown_seconds <= 0:
1458
+ return
1459
+ _usage_refresh_auth_cooldowns[account_id] = time.monotonic() + cooldown_seconds
1460
+
1461
+
1462
+ def _is_usage_refresh_in_cooldown(account_id: str) -> bool:
1463
+ expires_at = _usage_refresh_auth_cooldowns.get(account_id)
1464
+ if expires_at is None:
1465
+ return False
1466
+ if expires_at > time.monotonic():
1467
+ return True
1468
+ _usage_refresh_auth_cooldowns.pop(account_id, None)
1469
+ return False
1470
+
1471
+
1472
+ def _clear_usage_refresh_auth_cooldown(account_id: str) -> None:
1473
+ _usage_refresh_auth_cooldowns.pop(account_id, None)
1474
+
1475
+
1476
+ def _prune_usage_refresh_auth_cooldowns() -> None:
1477
+ now = time.monotonic()
1478
+ stale = [account_id for account_id, expires_at in _usage_refresh_auth_cooldowns.items() if expires_at <= now]
1479
+ for account_id in stale:
1480
+ _usage_refresh_auth_cooldowns.pop(account_id, None)
1481
+
1482
+
1483
+ def _clear_usage_refresh_state() -> None:
1484
+ _usage_refresh_auth_cooldowns.clear()
1485
+ _usage_request_refresh_deadlines.clear()
1486
+ _last_successful_refresh.clear()
1487
+ _FALLBACK_PLAN_DOWNGRADE_OBSERVATIONS.clear_all()
1488
+ _USAGE_REFRESH_SINGLEFLIGHT.clear()