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,1612 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import math
6
+ import random
7
+ import socket
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Collection, Iterable, Literal
11
+
12
+ from app.core.balancer.types import FailureClass, UpstreamError
13
+ from app.core.usage import PLAN_CAPACITY_CREDITS_SECONDARY
14
+ from app.core.utils.retry import backoff_seconds, parse_retry_after
15
+ from app.db.models import AccountStatus
16
+
17
+ PERMANENT_FAILURE_CODES = {
18
+ "refresh_token_expired": "Refresh token expired - re-login required",
19
+ "refresh_token_reused": "Refresh token was reused - re-login required",
20
+ "refresh_token_invalidated": "Refresh token was revoked - re-login required",
21
+ "invalid_grant": "Refresh token grant invalid - re-login required",
22
+ "token_invalidated": "Authentication token invalidated - re-login required",
23
+ # ``token_expired`` from the OAuth refresh endpoint means the refresh
24
+ # request itself failed because the refresh token (or the session it
25
+ # belonged to) is no longer usable -- access-token-only expiry would have
26
+ # returned a fresh token pair instead. Treat it as a permanent refresh
27
+ # failure while ordinary requests may continue with the stored access token.
28
+ "token_expired": "Authentication token expired - re-login required",
29
+ "app_session_terminated": "ChatGPT session ended - re-login required",
30
+ "account_session_expired": "ChatGPT session ended - re-login required",
31
+ "account_auth_invalidated": "Authentication failed after token refresh - re-login required",
32
+ # The OAuth endpoint uses this code for an invalid/revoked refresh token.
33
+ # Keep it in the permanent set so the account is surfaced for re-auth
34
+ # instead of remaining active while every request retries the dead token.
35
+ "invalid_refresh_token": "Refresh token invalid - re-login required",
36
+ "account_deactivated": "Account has been deactivated",
37
+ "deactivated_workspace": "Workspace has been deactivated",
38
+ "account_suspended": "Account has been suspended",
39
+ "account_deleted": "Account has been deleted",
40
+ }
41
+
42
+ REAUTH_REQUIRED_FAILURE_CODES = frozenset(
43
+ {
44
+ "refresh_token_expired",
45
+ "refresh_token_reused",
46
+ "refresh_token_invalidated",
47
+ "invalid_grant",
48
+ "token_invalidated",
49
+ "token_expired",
50
+ "app_session_terminated",
51
+ "account_session_expired",
52
+ "account_auth_invalidated",
53
+ "invalid_refresh_token",
54
+ }
55
+ )
56
+
57
+ SECONDS_PER_DAY = 60 * 60 * 24
58
+ SECONDS_PER_HOUR = 60 * 60
59
+ SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY
60
+ RATE_LIMIT_RESET_MAX_HORIZON_SECONDS = 366 * SECONDS_PER_DAY
61
+ RATE_LIMIT_RESET_ROUNDING_TOLERANCE_SECONDS = 1.0
62
+ UNKNOWN_RESET_BUCKET_DAYS = 10_000
63
+ UNKNOWN_RESET_FALLBACK_SECONDS = 7 * SECONDS_PER_DAY
64
+ RELATIVE_AVAILABILITY_MIN_DIVISOR_SECONDS = 5 * 60
65
+ RELATIVE_AVAILABILITY_MIN_WEIGHT_FRACTION = 0.1
66
+ DEFAULT_RELATIVE_AVAILABILITY_POWER = 2.0
67
+ DEFAULT_RELATIVE_AVAILABILITY_TOP_K = 5
68
+ RoutingStrategy = Literal[
69
+ "usage_weighted",
70
+ "round_robin",
71
+ "capacity_weighted",
72
+ "relative_availability",
73
+ "fill_first",
74
+ "sequential_drain",
75
+ "reset_drain",
76
+ "single_account",
77
+ ]
78
+ TrafficClass = Literal["foreground", "opportunistic"]
79
+ UsageWeightedOrder = Literal["secondary_first", "primary_first"]
80
+ ResetPreferenceWindow = Literal["primary", "secondary"]
81
+ UNKNOWN_PLAN_FALLBACK = "free"
82
+ CAPACITY_PLAN_ALIASES = {
83
+ "education": "edu",
84
+ "k12": "edu",
85
+ "guest": "free",
86
+ "go": "free",
87
+ "free_workspace": "free",
88
+ "quorum": "free",
89
+ "unknown": "free",
90
+ }
91
+
92
+ HEALTH_TIER_HEALTHY = 0
93
+ HEALTH_TIER_DRAINING = 1
94
+ HEALTH_TIER_PROBING = 2
95
+
96
+ DRAIN_PRIMARY_THRESHOLD_PCT = 85.0
97
+ DRAIN_SECONDARY_THRESHOLD_PCT = 90.0
98
+ DRAIN_ERROR_WINDOW_SECONDS = 60.0
99
+ DRAIN_ERROR_COUNT_THRESHOLD = 2
100
+ ERROR_BACKOFF_THRESHOLD = 3
101
+ PROBE_QUIET_SECONDS = 60.0
102
+ PROBE_SUCCESS_STREAK_REQUIRED = 3
103
+ ROUTING_POLICY_NORMAL = "normal"
104
+ ROUTING_POLICY_BURN_FIRST = "burn_first"
105
+ ROUTING_POLICY_PRESERVE = "preserve"
106
+ TRAFFIC_CLASS_FOREGROUND = "foreground"
107
+ TRAFFIC_CLASS_OPPORTUNISTIC = "opportunistic"
108
+ PRESERVE_MIN_WEEKLY_FLOOR_PCT = 5.0
109
+ PRESERVE_MIN_SHORT_WINDOW_FLOOR_PCT = 10.0
110
+ NORMAL_LAST_ACCOUNT_EMERGENCY_FLOOR_PCT = 5.0
111
+ RECENT_FOREGROUND_ACTIVITY_SECONDS = 30 * 60
112
+
113
+ logger = logging.getLogger(__name__)
114
+
115
+ _RELATIVE_AVAILABILITY_LOG_PREFIX_CANDIDATE = "Relative availability candidate "
116
+ _RELATIVE_AVAILABILITY_LOG_PREFIX_TOP_K = "Relative availability top-k "
117
+ _RELATIVE_AVAILABILITY_LOG_PREFIX_WINNER = "Relative availability winner "
118
+
119
+
120
+ @dataclass
121
+ class AccountState:
122
+ account_id: str
123
+ status: AccountStatus
124
+ used_percent: float | None = None
125
+ reset_at: float | None = None
126
+ primary_reset_at: int | None = None
127
+ primary_window_minutes: int | None = None
128
+ blocked_at: float | None = None
129
+ cooldown_until: float | None = None
130
+ secondary_used_percent: float | None = None
131
+ secondary_reset_at: int | None = None
132
+ last_error_at: float | None = None
133
+ last_selected_at: float | None = None
134
+ error_count: int = 0
135
+ deactivation_reason: str | None = None
136
+ plan_type: str | None = None
137
+ capacity_credits: float | None = None
138
+ health_tier: int = 0
139
+ priority_used_percent: float | None = None
140
+ priority_secondary_used_percent: float | None = None
141
+ priority_reset_at: int | None = None
142
+ priority_capacity_credits: float | None = None
143
+ limit_scoped_usage: bool = False
144
+ access_token_expires_at: float | None = None
145
+ inflight_response_creates: int = 0
146
+ inflight_streams: int = 0
147
+ leased_tokens: float = 0.0
148
+ routing_policy: str = ROUTING_POLICY_NORMAL
149
+ ignore_standard_quota: bool = False
150
+ # Multiplier applied to this candidate's draw weight by the weighted
151
+ # strategies (``capacity_weighted``, ``relative_availability``); ``1.0`` is
152
+ # neutral. The balancer derives it from the account's recent upstream
153
+ # error rate. Deterministic strategies ignore it.
154
+ selection_weight_multiplier: float = 1.0
155
+
156
+
157
+ @dataclass
158
+ class SelectionResult:
159
+ account: AccountState | None
160
+ error_message: str | None
161
+ error_code: str | None = None
162
+ resets_at: int | None = None
163
+
164
+
165
+ USAGE_LIMIT_REACHED = "usage_limit_reached"
166
+
167
+
168
+ def pool_usage_exhaustion(
169
+ states: Iterable[AccountState],
170
+ *,
171
+ current: float,
172
+ ignore_standard_quota: bool = False,
173
+ ignore_standard_quota_account_ids: Collection[str] | None = None,
174
+ ) -> SelectionResult | None:
175
+ """Describe pool-wide subscription exhaustion without parsing retry text."""
176
+ if ignore_standard_quota:
177
+ return None
178
+ ignored_account_ids = set(ignore_standard_quota_account_ids or ())
179
+
180
+ def _primary_usage_evidence(state: AccountState) -> float | None:
181
+ return state.priority_used_percent if state.priority_used_percent is not None else state.used_percent
182
+
183
+ def _secondary_usage_evidence(state: AccountState) -> float | None:
184
+ if state.limit_scoped_usage and state.priority_secondary_used_percent is None:
185
+ return _primary_usage_evidence(state)
186
+ return (
187
+ state.priority_secondary_used_percent
188
+ if state.priority_secondary_used_percent is not None
189
+ else state.secondary_used_percent
190
+ )
191
+
192
+ def _usage_exhausted(state: AccountState) -> bool:
193
+ if state.status not in (AccountStatus.QUOTA_EXCEEDED, AccountStatus.RATE_LIMITED):
194
+ return False
195
+ usage_values = (_primary_usage_evidence(state), _secondary_usage_evidence(state))
196
+ return any(value is not None and float(value) >= 100.0 for value in usage_values)
197
+
198
+ def _usage_exhausted_reset_at(state: AccountState) -> float | None:
199
+ if state.status not in (AccountStatus.QUOTA_EXCEEDED, AccountStatus.RATE_LIMITED):
200
+ return None
201
+ candidates: list[float] = []
202
+ primary_evidence = _primary_usage_evidence(state)
203
+ secondary_evidence = _secondary_usage_evidence(state)
204
+ if primary_evidence is not None and float(primary_evidence) >= 100.0 and state.primary_reset_at is not None:
205
+ candidates.append(float(state.primary_reset_at))
206
+ if (
207
+ secondary_evidence is not None
208
+ and float(secondary_evidence) >= 100.0
209
+ and state.secondary_reset_at is not None
210
+ ):
211
+ candidates.append(float(state.secondary_reset_at))
212
+ if not candidates:
213
+ return None
214
+ return max(candidates)
215
+
216
+ eligible = [
217
+ state
218
+ for state in states
219
+ if not state.ignore_standard_quota
220
+ and state.account_id not in ignored_account_ids
221
+ and state.status
222
+ not in (
223
+ AccountStatus.PAUSED,
224
+ AccountStatus.DEACTIVATED,
225
+ )
226
+ ]
227
+ if not eligible or any(not _usage_exhausted(state) for state in eligible):
228
+ return None
229
+
230
+ # Only usage-proven exhausted accounts reach this branch; surface the
231
+ # earliest reset for an actually exhausted window so the structured 429
232
+ # does not retry a secondary-window exhaustion at the primary reset time.
233
+ reset_candidates = [reset_at for state in eligible if (reset_at := _usage_exhausted_reset_at(state)) is not None]
234
+ resets_at = int(min(reset_candidates)) if reset_candidates else None
235
+ message = "Usage limit reached"
236
+ if resets_at is not None:
237
+ message = _format_retry_hint(max(0.0, resets_at - current))
238
+ return SelectionResult(
239
+ account=None,
240
+ error_message=message,
241
+ error_code=USAGE_LIMIT_REACHED,
242
+ resets_at=resets_at,
243
+ )
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class RoutingCost:
248
+ """Request-scoped planner cost applied after hard eligibility filters."""
249
+
250
+ total: float = 0.0
251
+ reason: str | None = None
252
+
253
+
254
+ RoutingCostsByAccount = dict[str, RoutingCost]
255
+
256
+
257
+ def _planner_cost(state: AccountState, routing_costs: RoutingCostsByAccount | None) -> float:
258
+ if not routing_costs:
259
+ return 0.0
260
+ cost = routing_costs.get(state.account_id)
261
+ if cost is None:
262
+ return 0.0
263
+ return float(cost.total)
264
+
265
+
266
+ def _usage_sort_key(state: AccountState) -> tuple[float, float, float, str]:
267
+ primary_used = _priority_primary_used(state)
268
+ secondary_used = _priority_secondary_used(state, primary_used)
269
+ last_selected = state.last_selected_at or 0.0
270
+ return secondary_used, primary_used, last_selected, state.account_id
271
+
272
+
273
+ def _primary_usage_sort_key(state: AccountState) -> tuple[float, float, float, str]:
274
+ primary_used = _priority_primary_used(state)
275
+ secondary_used = _priority_secondary_used(state, primary_used)
276
+ last_selected = state.last_selected_at or 0.0
277
+ return primary_used, secondary_used, last_selected, state.account_id
278
+
279
+
280
+ def _routing_policy(state: AccountState) -> str:
281
+ if state.routing_policy in {
282
+ ROUTING_POLICY_BURN_FIRST,
283
+ ROUTING_POLICY_NORMAL,
284
+ ROUTING_POLICY_PRESERVE,
285
+ }:
286
+ return state.routing_policy
287
+ return ROUTING_POLICY_NORMAL
288
+
289
+
290
+ def _used_pct(state: AccountState, *, secondary: bool) -> float | None:
291
+ if secondary:
292
+ return state.secondary_used_percent if state.secondary_used_percent is not None else state.used_percent
293
+ return state.used_percent
294
+
295
+
296
+ def _remaining_pct(state: AccountState, *, secondary: bool) -> float | None:
297
+ used_pct = _used_pct(state, secondary=secondary)
298
+ if used_pct is None:
299
+ return None
300
+ return max(0.0, 100.0 - min(100.0, used_pct))
301
+
302
+
303
+ def _seconds_until(reset_at: int | float | None, current: float) -> float | None:
304
+ if reset_at is None:
305
+ return None
306
+ return max(0.0, float(reset_at) - current)
307
+
308
+
309
+ def _recent_foreground_activity(state: AccountState, current: float) -> bool:
310
+ return state.last_selected_at is not None and current - state.last_selected_at <= RECENT_FOREGROUND_ACTIVITY_SECONDS
311
+
312
+
313
+ def _weekly_pace_floor_pct(state: AccountState, current: float) -> float:
314
+ remaining_seconds = _seconds_until(state.secondary_reset_at, current)
315
+ used_pct = _used_pct(state, secondary=True)
316
+ if remaining_seconds is None or used_pct is None:
317
+ return 100.0
318
+
319
+ elapsed_seconds = max(0.0, SECONDS_PER_WEEK - remaining_seconds)
320
+ expected_used_pct = min(100.0, (elapsed_seconds / SECONDS_PER_WEEK) * 100.0)
321
+ behind_pace = used_pct + 5.0 < expected_used_pct
322
+
323
+ if remaining_seconds <= 6 * SECONDS_PER_HOUR and behind_pace:
324
+ pace_floor = 0.0
325
+ elif remaining_seconds <= SECONDS_PER_DAY and behind_pace:
326
+ pace_floor = 2.0
327
+ elif behind_pace:
328
+ pace_floor = 5.0
329
+ else:
330
+ pace_floor = 15.0
331
+
332
+ if _recent_foreground_activity(state, current):
333
+ pace_floor = max(pace_floor, 25.0)
334
+
335
+ return max(PRESERVE_MIN_WEEKLY_FLOOR_PCT, pace_floor)
336
+
337
+
338
+ def _short_window_floor_pct(state: AccountState, current: float, *, preserve_count: int) -> float:
339
+ remaining_seconds = _seconds_until(state.reset_at, current)
340
+ floor = PRESERVE_MIN_SHORT_WINDOW_FLOOR_PCT
341
+ if remaining_seconds is None:
342
+ return 100.0
343
+ if remaining_seconds > SECONDS_PER_HOUR:
344
+ floor = max(floor, 20.0)
345
+ if _recent_foreground_activity(state, current):
346
+ floor = max(floor, 30.0)
347
+ if preserve_count <= 1:
348
+ floor = max(floor, 25.0)
349
+ return floor
350
+
351
+
352
+ def _preserve_allows_opportunistic_burn(state: AccountState, current: float, *, preserve_count: int) -> bool:
353
+ if _remaining_pct(state, secondary=True) is None or _remaining_pct(state, secondary=False) is None:
354
+ return False
355
+ if state.secondary_reset_at is None or state.reset_at is None:
356
+ return False
357
+ weekly_floor = _weekly_pace_floor_pct(state, current)
358
+ short_floor = _short_window_floor_pct(state, current, preserve_count=preserve_count)
359
+ return (_remaining_pct(state, secondary=True) or 0.0) > weekly_floor and (
360
+ _remaining_pct(state, secondary=False) or 0.0
361
+ ) > short_floor
362
+
363
+
364
+ def _has_other_usable_foreground_capacity(
365
+ candidate: AccountState,
366
+ available: list[AccountState],
367
+ current: float,
368
+ ) -> bool:
369
+ preserve_count = sum(1 for state in available if _routing_policy(state) == ROUTING_POLICY_PRESERVE)
370
+ for other in available:
371
+ if other.account_id == candidate.account_id:
372
+ continue
373
+ if other.status not in (AccountStatus.ACTIVE, AccountStatus.REAUTH_REQUIRED):
374
+ continue
375
+ if _routing_policy(other) == ROUTING_POLICY_PRESERVE:
376
+ if _preserve_allows_opportunistic_burn(other, current, preserve_count=preserve_count):
377
+ return True
378
+ continue
379
+ return True
380
+ return False
381
+
382
+
383
+ def _above_emergency_floor(state: AccountState) -> bool:
384
+ primary_remaining = _remaining_pct(state, secondary=False)
385
+ secondary_remaining = _remaining_pct(state, secondary=True)
386
+ if primary_remaining is None or secondary_remaining is None:
387
+ return False
388
+ return (
389
+ primary_remaining > NORMAL_LAST_ACCOUNT_EMERGENCY_FLOOR_PCT
390
+ and secondary_remaining > NORMAL_LAST_ACCOUNT_EMERGENCY_FLOOR_PCT
391
+ )
392
+
393
+
394
+ def _filter_opportunistic_candidates(
395
+ available: list[AccountState],
396
+ current: float,
397
+ ) -> tuple[list[AccountState], str | None]:
398
+ burn_first: list[AccountState] = []
399
+ normal: list[AccountState] = []
400
+ preserve: list[AccountState] = []
401
+ preserve_count = sum(1 for state in available if _routing_policy(state) == ROUTING_POLICY_PRESERVE)
402
+
403
+ for state in available:
404
+ policy = _routing_policy(state)
405
+ if policy == ROUTING_POLICY_BURN_FIRST:
406
+ if _has_other_usable_foreground_capacity(state, available, current) or _above_emergency_floor(state):
407
+ burn_first.append(state)
408
+ elif policy == ROUTING_POLICY_PRESERVE:
409
+ if _preserve_allows_opportunistic_burn(state, current, preserve_count=preserve_count):
410
+ preserve.append(state)
411
+ else:
412
+ if _has_other_usable_foreground_capacity(state, available, current) or _above_emergency_floor(state):
413
+ normal.append(state)
414
+
415
+ if burn_first or normal or preserve:
416
+ return [*burn_first, *normal, *preserve], None
417
+
418
+ if any(_routing_policy(state) == ROUTING_POLICY_PRESERVE for state in available):
419
+ return [], "preserve floor or stale usage data blocks opportunistic burn"
420
+ return [], "no expendable account has emergency foreground reserve"
421
+
422
+
423
+ def _reset_preference_bucket(state: AccountState, current: float, window: ResetPreferenceWindow) -> int:
424
+ if window == "primary":
425
+ reset_at = state.primary_reset_at
426
+ if reset_at is None:
427
+ reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
428
+ else:
429
+ reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
430
+ if reset_at is None:
431
+ reset_at = state.primary_reset_at
432
+ if reset_at is None:
433
+ return UNKNOWN_RESET_BUCKET_DAYS * SECONDS_PER_DAY
434
+ remaining_seconds = max(0, int(reset_at - current))
435
+ if window == "secondary":
436
+ return remaining_seconds // SECONDS_PER_DAY
437
+ return remaining_seconds
438
+
439
+
440
+ def _prefer_earlier_reset_candidates(
441
+ available: list[AccountState],
442
+ current: float,
443
+ window: ResetPreferenceWindow,
444
+ ) -> list[AccountState]:
445
+ earliest_bucket = min(_reset_preference_bucket(state, current, window) for state in available)
446
+ return [state for state in available if _reset_preference_bucket(state, current, window) == earliest_bucket]
447
+
448
+
449
+ def _fallback_secondary_capacity_credits(plan_type: str | None) -> float:
450
+ normalized = (plan_type or "").strip().lower()
451
+ resolved_plan = CAPACITY_PLAN_ALIASES.get(normalized, normalized or UNKNOWN_PLAN_FALLBACK)
452
+ return PLAN_CAPACITY_CREDITS_SECONDARY.get(
453
+ resolved_plan,
454
+ PLAN_CAPACITY_CREDITS_SECONDARY[UNKNOWN_PLAN_FALLBACK],
455
+ )
456
+
457
+
458
+ def _known_expired_reauth(state: AccountState, current: float) -> bool:
459
+ """Return whether a warning-state account has crossed known token expiry."""
460
+ return (
461
+ state.status == AccountStatus.REAUTH_REQUIRED
462
+ and state.access_token_expires_at is not None
463
+ and state.access_token_expires_at <= current
464
+ )
465
+
466
+
467
+ def select_account(
468
+ states: Iterable[AccountState],
469
+ now: float | None = None,
470
+ *,
471
+ prefer_earlier_reset: bool = False,
472
+ prefer_earlier_reset_window: ResetPreferenceWindow = "secondary",
473
+ routing_strategy: RoutingStrategy = "capacity_weighted",
474
+ allow_backoff_fallback: bool = True,
475
+ deterministic_probe: bool = False,
476
+ recovery_probe_only: bool = False,
477
+ relative_availability_power: float = DEFAULT_RELATIVE_AVAILABILITY_POWER,
478
+ relative_availability_top_k: int = DEFAULT_RELATIVE_AVAILABILITY_TOP_K,
479
+ usage_weighted_order: UsageWeightedOrder = "secondary_first",
480
+ traffic_class: TrafficClass = TRAFFIC_CLASS_FOREGROUND,
481
+ ignore_standard_quota: bool = False,
482
+ bypass_quota_exceeded: bool = False,
483
+ bypass_quota_exceeded_account_ids: Collection[str] | None = None,
484
+ primary_first_usage_weighted: bool = False,
485
+ routing_costs: RoutingCostsByAccount | None = None,
486
+ replica_salt: str | None = None,
487
+ selection_seed: str | None = None,
488
+ allow_usage_exhaustion_error: bool = True,
489
+ usage_exhaustion_states: Iterable[AccountState] | None = None,
490
+ ) -> SelectionResult:
491
+ """Select an eligible account by applying availability checks and routing strategy.
492
+
493
+ This function filters out accounts that cannot currently serve traffic
494
+ (for example paused, deactivated, still rate-limited, or in active
495
+ cooldown), attempts controlled recovery from transient error backoff,
496
+ and then chooses a candidate using the configured balancing strategy.
497
+
498
+ Args:
499
+ states: Candidate account states to evaluate for the current request.
500
+ now: Unix timestamp in seconds used as the evaluation clock. If
501
+ ``None``, the current system time is used.
502
+ prefer_earlier_reset: Whether to bias selection toward accounts whose
503
+ configured quota window resets sooner.
504
+ prefer_earlier_reset_window: Quota window used for reset preference
505
+ (``"primary"`` for short window or ``"secondary"`` for weekly),
506
+ with the other window used as fallback when the configured reset is
507
+ unavailable.
508
+ routing_strategy: Balancing strategy used to pick from the effective
509
+ pool (``"capacity_weighted"``, ``"sequential_drain"``,
510
+ ``"reset_drain"``, ``"single_account"``, ``"relative_availability"``,
511
+ ``"fill_first"``, ``"round_robin"``, or ``"usage_weighted"``).
512
+ allow_backoff_fallback: Whether to allow a fallback attempt with the
513
+ backoff account nearest to recovery when no fully available
514
+ account exists.
515
+ deterministic_probe: Whether weighted strategies should use a
516
+ deterministic probe order instead of random weighted choice.
517
+ recovery_probe_only: Whether to return only a due probing-account
518
+ recovery admission and otherwise return no selection. Health-tier
519
+ bypass strategies ignore this internal mode.
520
+ relative_availability_power: Exponent applied to normalized relative
521
+ availability weights.
522
+ relative_availability_top_k: Maximum number of highest-weight
523
+ relative-availability candidates retained before weighted draw.
524
+ usage_weighted_order: Whether usage-weighted routing ranks secondary
525
+ window pressure first, or primary-window pressure first for
526
+ budget-safe fallback selection.
527
+ traffic_class: Whether the request is normal foreground traffic or
528
+ opportunistic traffic that may only use explicitly expendable
529
+ account capacity.
530
+ ignore_standard_quota: Whether to ignore the account's standard
531
+ primary/secondary quota status. This is only for models that are
532
+ gated by a separate additional quota pool.
533
+ bypass_quota_exceeded: Backward-compatible alias for ignoring standard
534
+ quota status for gated quota selection.
535
+ bypass_quota_exceeded_account_ids: Optional narrower account-id scope
536
+ for the same bypass.
537
+ primary_first_usage_weighted: Whether usage-weighted routing should
538
+ rank by primary-window pressure before secondary-window pressure.
539
+ routing_costs: Optional request-scoped planner costs. Lower cost wins
540
+ after hard eligibility, health tier, and reset-bucket filtering.
541
+ selection_seed: Optional seed making the pick stable for a given
542
+ seed value instead of load-proportional. Applied to the pool the
543
+ configured strategy has already narrowed to, so it decides only
544
+ which of the accounts the strategy was about to draw from is
545
+ taken. Drain strategies (``sequential_drain``, ``reset_drain``,
546
+ ``single_account``) have one correct answer and ignore it.
547
+ replica_salt: Optional per-replica salt mixed into the final
548
+ ``round_robin`` tie-break so peer replicas break exact ties toward
549
+ different accounts. When ``None``, the process-wide salt configured
550
+ via ``configure_replica_salt`` (else the host identity) is used. It
551
+ affects only the final tie-break; the primary usage/health/cost
552
+ ordering is unchanged.
553
+
554
+ Returns:
555
+ A ``SelectionResult`` containing the selected ``AccountState`` and no
556
+ error message when routing can proceed, or ``None`` plus a
557
+ human-readable error message when no account is eligible.
558
+ """
559
+ current = now or time.time()
560
+ available: list[AccountState] = []
561
+ in_error_backoff: list[AccountState] = []
562
+ all_states = list(states)
563
+ usage_exhaustion_state_list = list(usage_exhaustion_states) if usage_exhaustion_states is not None else all_states
564
+ bypass_account_ids = None if bypass_quota_exceeded_account_ids is None else set(bypass_quota_exceeded_account_ids)
565
+
566
+ for state in all_states:
567
+ bypass_standard_quota = (
568
+ ignore_standard_quota
569
+ or state.ignore_standard_quota
570
+ or bypass_quota_exceeded
571
+ or (bypass_account_ids is not None and state.account_id in bypass_account_ids)
572
+ )
573
+ if state.status == AccountStatus.DEACTIVATED:
574
+ continue
575
+ if state.status == AccountStatus.PAUSED:
576
+ continue
577
+ if _known_expired_reauth(state, current):
578
+ continue
579
+ if state.status == AccountStatus.RATE_LIMITED:
580
+ if state.reset_at and current >= state.reset_at:
581
+ state.status = AccountStatus.ACTIVE
582
+ state.used_percent = 0.0
583
+ state.error_count = 0
584
+ state.reset_at = None
585
+ elif not bypass_standard_quota:
586
+ continue
587
+ if state.status == AccountStatus.QUOTA_EXCEEDED:
588
+ if state.reset_at and current >= state.reset_at:
589
+ state.status = AccountStatus.ACTIVE
590
+ state.used_percent = 0.0
591
+ state.secondary_used_percent = 0.0
592
+ state.reset_at = None
593
+ elif not bypass_standard_quota:
594
+ continue
595
+ if state.cooldown_until and current >= state.cooldown_until:
596
+ state.cooldown_until = None
597
+ state.last_error_at = None
598
+ state.error_count = 0
599
+ if state.cooldown_until and current < state.cooldown_until:
600
+ continue
601
+ if state.error_count >= ERROR_BACKOFF_THRESHOLD:
602
+ backoff = min(300, 30 * (2 ** (state.error_count - ERROR_BACKOFF_THRESHOLD)))
603
+ if state.last_error_at and current - state.last_error_at < backoff:
604
+ in_error_backoff.append(state)
605
+ continue
606
+ # Error backoff expired — reset error state so recovery is
607
+ # not penalised by stale counts. The account has already
608
+ # been held back for the full backoff period; letting it
609
+ # re-enter the pool with a clean slate avoids the problem
610
+ # where a previously-high error_count causes an immediate
611
+ # return to maximum backoff on the very next transient error.
612
+ state.error_count = 0
613
+ state.last_error_at = None
614
+ available.append(state)
615
+
616
+ if traffic_class == TRAFFIC_CLASS_OPPORTUNISTIC and available:
617
+ opportunistic_available, reason = _filter_opportunistic_candidates(available, current)
618
+ if not opportunistic_available:
619
+ return SelectionResult(None, f"opportunistic burn window closed: {reason}")
620
+ available = opportunistic_available
621
+
622
+ if not available:
623
+ in_error_backoff_ids = {state.account_id for state in in_error_backoff}
624
+ hard_blocked_exists = any(
625
+ (
626
+ state.status
627
+ in (
628
+ AccountStatus.PAUSED,
629
+ AccountStatus.DEACTIVATED,
630
+ AccountStatus.RATE_LIMITED,
631
+ AccountStatus.QUOTA_EXCEEDED,
632
+ )
633
+ or _known_expired_reauth(state, current)
634
+ )
635
+ and state.account_id not in in_error_backoff_ids
636
+ for state in all_states
637
+ )
638
+ if allow_backoff_fallback and (len(in_error_backoff) > 1 or (in_error_backoff and hard_blocked_exists)):
639
+
640
+ def _backoff_expires_at(s: AccountState) -> float:
641
+ backoff = min(300, 30 * (2 ** (s.error_count - ERROR_BACKOFF_THRESHOLD)))
642
+ return (s.last_error_at or 0.0) + backoff
643
+
644
+ available.append(min(in_error_backoff, key=_backoff_expires_at))
645
+ if traffic_class == TRAFFIC_CLASS_OPPORTUNISTIC:
646
+ opportunistic_available, reason = _filter_opportunistic_candidates(available, current)
647
+ if not opportunistic_available:
648
+ return SelectionResult(None, f"opportunistic burn window closed: {reason}")
649
+ available = opportunistic_available
650
+ else:
651
+ if allow_usage_exhaustion_error:
652
+ usage_exhaustion = pool_usage_exhaustion(
653
+ usage_exhaustion_state_list,
654
+ current=current,
655
+ ignore_standard_quota=ignore_standard_quota or bypass_quota_exceeded,
656
+ ignore_standard_quota_account_ids=bypass_account_ids,
657
+ )
658
+ if usage_exhaustion is not None:
659
+ return usage_exhaustion
660
+ expired_reauth = [state for state in all_states if _known_expired_reauth(state, current)]
661
+ deactivated = [s for s in all_states if s.status == AccountStatus.DEACTIVATED]
662
+ paused = [s for s in all_states if s.status == AccountStatus.PAUSED]
663
+ rate_limited = [s for s in all_states if s.status == AccountStatus.RATE_LIMITED]
664
+ quota_exceeded = [s for s in all_states if s.status == AccountStatus.QUOTA_EXCEEDED]
665
+
666
+ if not rate_limited and not quota_exceeded:
667
+ if paused and expired_reauth and deactivated:
668
+ return SelectionResult(None, "All accounts are paused, deactivated, or require re-authentication")
669
+ if paused and expired_reauth:
670
+ return SelectionResult(None, "All accounts are paused or require re-authentication")
671
+ if paused and deactivated:
672
+ return SelectionResult(None, "All accounts are paused or deactivated")
673
+ if expired_reauth and deactivated:
674
+ return SelectionResult(None, "All accounts are deactivated or require re-authentication")
675
+ if paused:
676
+ return SelectionResult(None, "All accounts are paused")
677
+ if expired_reauth:
678
+ return SelectionResult(None, "All accounts require re-authentication")
679
+ if deactivated:
680
+ return SelectionResult(None, "All accounts are deactivated")
681
+ if quota_exceeded:
682
+ reset_candidates = [s.reset_at for s in quota_exceeded if s.reset_at]
683
+ if reset_candidates:
684
+ wait_seconds = max(0, min(reset_candidates) - int(current))
685
+ return SelectionResult(None, _format_retry_hint(wait_seconds))
686
+ cooldowns = [s.cooldown_until for s in all_states if s.cooldown_until and s.cooldown_until > current]
687
+ if cooldowns:
688
+ wait_seconds = max(0.0, min(cooldowns) - current)
689
+ return SelectionResult(None, _format_retry_hint(wait_seconds))
690
+ return SelectionResult(None, "No available accounts")
691
+
692
+ def _reset_first_sort_key(state: AccountState) -> tuple[int, float, float, float, float, str]:
693
+ reset_bucket_days = _reset_preference_bucket(state, current, prefer_earlier_reset_window)
694
+ secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
695
+ return (
696
+ reset_bucket_days,
697
+ _planner_cost(state, routing_costs),
698
+ secondary_used,
699
+ primary_used,
700
+ last_selected,
701
+ account_id,
702
+ )
703
+
704
+ def _primary_reset_first_sort_key(state: AccountState) -> tuple[int, float, float, float, float, str]:
705
+ reset_bucket_days = _reset_preference_bucket(state, current, prefer_earlier_reset_window)
706
+ primary_used, secondary_used, last_selected, account_id = _primary_usage_sort_key(state)
707
+ return (
708
+ reset_bucket_days,
709
+ _planner_cost(state, routing_costs),
710
+ primary_used,
711
+ secondary_used,
712
+ last_selected,
713
+ account_id,
714
+ )
715
+
716
+ def _primary_usage_sort_key_with_cost(state: AccountState) -> tuple[float, float, float, float, str]:
717
+ primary_used, secondary_used, last_selected, account_id = _primary_usage_sort_key(state)
718
+ return _planner_cost(state, routing_costs), primary_used, secondary_used, last_selected, account_id
719
+
720
+ def _usage_sort_key_with_cost(state: AccountState) -> tuple[float, float, float, float, str]:
721
+ secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
722
+ return _planner_cost(state, routing_costs), secondary_used, primary_used, last_selected, account_id
723
+
724
+ def _seeded_pick(pool: list[AccountState], seed: str) -> AccountState:
725
+ """The strategy's draw, replaced by a stable per-seed choice.
726
+
727
+ Applied to the pool the strategy has already narrowed to -- its planner
728
+ cost and reset-preference filters run first -- so the seed decides only
729
+ *which* of the accounts the strategy was about to draw from is taken,
730
+ never whether an account it excluded becomes eligible.
731
+ """
732
+ return _seeded_account(pool, seed)
733
+
734
+ round_robin_salt = _effective_replica_salt(replica_salt)
735
+
736
+ def _round_robin_sort_key(state: AccountState) -> tuple[float, float, str]:
737
+ # Primary ordering: lowest planner cost, then least-recently-selected.
738
+ # The final tie-break is decorrelated per replica (keyed hash of the
739
+ # account id) so peers spread exact ties across equally-good accounts
740
+ # instead of all herding onto the lexicographically-first account.
741
+ return (
742
+ _planner_cost(state, routing_costs),
743
+ state.last_selected_at or 0.0,
744
+ _decorrelated_tie_breaker(state.account_id, round_robin_salt),
745
+ )
746
+
747
+ if routing_strategy == "single_account":
748
+ selected = min(available, key=lambda state: state.account_id)
749
+ return SelectionResult(selected, None)
750
+
751
+ if routing_strategy == "sequential_drain":
752
+ selected = min(available, key=_sequential_drain_sort_key)
753
+ return SelectionResult(selected, None)
754
+
755
+ if routing_strategy == "reset_drain":
756
+ selected = min(available, key=lambda state: _reset_drain_sort_key(state, current))
757
+ return SelectionResult(selected, None)
758
+
759
+ healthy = [s for s in available if s.health_tier == HEALTH_TIER_HEALTHY]
760
+ probing = [s for s in available if s.health_tier == HEALTH_TIER_PROBING]
761
+ draining = [s for s in available if s.health_tier == HEALTH_TIER_DRAINING]
762
+ # Recovery is a liveness admission inside the already-eligible pool. Pick
763
+ # it before routing-policy preferences so burn/preserve policy cannot make
764
+ # PROBING permanent, but never before quota/cooldown/security filtering.
765
+ # A seeded caller never carries the probe. The due probe is whichever
766
+ # probing account went quiet longest, so admitting one advances its clock
767
+ # and hands the next turn to a different sibling -- the rotation the seed
768
+ # exists to prevent, and the conversation fan-out the seed exists to stop.
769
+ # Probes ride on unbound traffic and on every other thread, so this exempts
770
+ # the handful of conversations being held warm through an isolation window
771
+ # rather than starving recovery. ``recovery_probe_only`` is the probe pass
772
+ # itself and is never seeded.
773
+ due_probe = (
774
+ _oldest_due_probing_account(probing, current=current)
775
+ if (healthy or recovery_probe_only) and selection_seed is None
776
+ else None
777
+ )
778
+ if recovery_probe_only and due_probe is None:
779
+ return SelectionResult(None, None)
780
+ health_pool = [due_probe] if due_probe is not None else healthy or probing or draining or available
781
+ burn_first = [s for s in health_pool if _routing_policy(s) == ROUTING_POLICY_BURN_FIRST]
782
+ normal = [s for s in health_pool if _routing_policy(s) == ROUTING_POLICY_NORMAL]
783
+ preserve = [s for s in health_pool if _routing_policy(s) == ROUTING_POLICY_PRESERVE]
784
+ effective_pool = burn_first or normal or preserve or health_pool
785
+ effective_prefer_earlier_reset = prefer_earlier_reset and routing_strategy != "relative_availability"
786
+
787
+ if routing_strategy == "round_robin":
788
+ selected = (
789
+ _seeded_pick(_lowest_planner_cost_candidates(effective_pool, routing_costs), selection_seed)
790
+ if selection_seed is not None
791
+ else min(effective_pool, key=_round_robin_sort_key)
792
+ )
793
+ elif routing_strategy == "capacity_weighted":
794
+ candidate_pool = (
795
+ _prefer_earlier_reset_candidates(effective_pool, current, prefer_earlier_reset_window)
796
+ if effective_prefer_earlier_reset
797
+ else effective_pool
798
+ )
799
+ if deterministic_probe:
800
+ selected = min(candidate_pool, key=lambda state: _capacity_probe_sort_key_with_cost(state, routing_costs))
801
+ else:
802
+ candidate_pool = _lowest_planner_cost_candidates(candidate_pool, routing_costs)
803
+ selected = _select_capacity_weighted(candidate_pool, selection_seed=selection_seed)
804
+ elif routing_strategy == "relative_availability":
805
+ candidate_pool = _lowest_planner_cost_candidates(effective_pool, routing_costs)
806
+ selected = _select_relative_availability(
807
+ candidate_pool,
808
+ current=current,
809
+ power=relative_availability_power,
810
+ top_k=relative_availability_top_k,
811
+ deterministic_probe=deterministic_probe,
812
+ selection_seed=selection_seed,
813
+ )
814
+ elif routing_strategy == "fill_first":
815
+ candidate_pool = (
816
+ _prefer_earlier_reset_candidates(effective_pool, current, prefer_earlier_reset_window)
817
+ if prefer_earlier_reset
818
+ else effective_pool
819
+ )
820
+ # ``fill_first`` deliberately ranks by *highest* usage to drain an
821
+ # account before opening the next, and that ranking is already stable
822
+ # across admissions, so the seed has nothing to add and would only
823
+ # scatter threads onto fresh accounts.
824
+ selected = _select_fill_first(candidate_pool)
825
+ else:
826
+ effective_usage_weighted_order: UsageWeightedOrder = (
827
+ "primary_first" if primary_first_usage_weighted else usage_weighted_order
828
+ )
829
+ if selection_seed is not None:
830
+ # The sort keys this branch would otherwise use put reset bucket
831
+ # first and planner cost second, so the seed has to be spent below
832
+ # both: a thread must not be held on a 30-day-reset or high-cost
833
+ # account the ordering had already ruled out.
834
+ seeded_pool = (
835
+ _prefer_earlier_reset_candidates(effective_pool, current, prefer_earlier_reset_window)
836
+ if effective_prefer_earlier_reset
837
+ else effective_pool
838
+ )
839
+ selected = _seeded_pick(_lowest_planner_cost_candidates(seeded_pool, routing_costs), selection_seed)
840
+ elif effective_usage_weighted_order == "primary_first":
841
+ selected = min(
842
+ effective_pool,
843
+ key=(
844
+ _primary_reset_first_sort_key
845
+ if effective_prefer_earlier_reset
846
+ else _primary_usage_sort_key_with_cost
847
+ ),
848
+ )
849
+ else:
850
+ selected = min(
851
+ effective_pool,
852
+ key=_reset_first_sort_key if effective_prefer_earlier_reset else _usage_sort_key_with_cost,
853
+ )
854
+ return SelectionResult(selected, None)
855
+
856
+
857
+ def _oldest_due_probing_account(
858
+ probing: Iterable[AccountState],
859
+ *,
860
+ current: float,
861
+ ) -> AccountState | None:
862
+ due = [
863
+ state
864
+ for state in probing
865
+ if state.last_selected_at is None or current - state.last_selected_at >= PROBE_QUIET_SECONDS
866
+ ]
867
+ if not due:
868
+ return None
869
+ return min(
870
+ due,
871
+ key=lambda state: (
872
+ state.last_selected_at if state.last_selected_at is not None else float("-inf"),
873
+ state.account_id,
874
+ ),
875
+ )
876
+
877
+
878
+ def _remaining_secondary_credits(state: AccountState) -> float:
879
+ """Return remaining absolute credits for the secondary (7-day) window."""
880
+ capacity = (
881
+ state.priority_capacity_credits if state.priority_capacity_credits is not None else state.capacity_credits
882
+ )
883
+ if capacity is None:
884
+ capacity = _fallback_secondary_capacity_credits(state.plan_type)
885
+ elif capacity <= 0:
886
+ return 0.0
887
+ primary_used = _priority_primary_used(state)
888
+ used_pct = _priority_secondary_used(state, primary_used)
889
+ return max(0.0, capacity * (1.0 - min(used_pct, 100.0) / 100.0))
890
+
891
+
892
+ def _priority_primary_used(state: AccountState) -> float:
893
+ value = state.priority_used_percent if state.priority_used_percent is not None else state.used_percent
894
+ return value if value is not None else 0.0
895
+
896
+
897
+ def _priority_secondary_used(state: AccountState, primary_used: float | None = None) -> float:
898
+ if state.limit_scoped_usage and state.priority_secondary_used_percent is None:
899
+ return primary_used if primary_used is not None else _priority_primary_used(state)
900
+ value = (
901
+ state.priority_secondary_used_percent
902
+ if state.priority_secondary_used_percent is not None
903
+ else state.secondary_used_percent
904
+ )
905
+ if value is not None:
906
+ return value
907
+ return primary_used if primary_used is not None else _priority_primary_used(state)
908
+
909
+
910
+ def _relative_availability_divisor_seconds(state: AccountState, current: float) -> float:
911
+ reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
912
+ if reset_at is None:
913
+ remaining_seconds = float(UNKNOWN_RESET_FALLBACK_SECONDS)
914
+ else:
915
+ remaining_seconds = max(0.0, float(reset_at) - current)
916
+ return max(remaining_seconds, float(RELATIVE_AVAILABILITY_MIN_DIVISOR_SECONDS))
917
+
918
+
919
+ def _relative_availability_remaining_seconds(state: AccountState, current: float) -> float:
920
+ reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
921
+ if reset_at is None:
922
+ return float(UNKNOWN_RESET_FALLBACK_SECONDS)
923
+ return max(0.0, float(reset_at) - current)
924
+
925
+
926
+ def _relative_availability_raw_score(state: AccountState, current: float) -> float:
927
+ remaining_credits = _remaining_secondary_credits(state)
928
+ if remaining_credits <= 0.0:
929
+ return 0.0
930
+ return remaining_credits / _relative_availability_divisor_seconds(state, current)
931
+
932
+
933
+ def _relative_availability_label(state: AccountState) -> str:
934
+ return state.account_id
935
+
936
+
937
+ def _relative_availability_score_per_minute(raw_score: float) -> float:
938
+ return raw_score * 60.0
939
+
940
+
941
+ def _log_relative_availability_candidate_scores(
942
+ raw_scores: list[tuple[AccountState, float]],
943
+ *,
944
+ current: float,
945
+ ) -> None:
946
+ for state, raw_score in raw_scores:
947
+ remaining_seconds = _relative_availability_remaining_seconds(state, current)
948
+ logger.debug(
949
+ (
950
+ f"{_RELATIVE_AVAILABILITY_LOG_PREFIX_CANDIDATE}account=%s "
951
+ "remaining_credits=%.2f remaining_minutes=%.2f score_per_minute=%.6f"
952
+ ),
953
+ _relative_availability_label(state),
954
+ _remaining_secondary_credits(state),
955
+ remaining_seconds / 60.0,
956
+ _relative_availability_score_per_minute(raw_score),
957
+ )
958
+
959
+
960
+ def _log_relative_availability_top_k(
961
+ weighted_candidates: list[tuple[AccountState, float, float]],
962
+ *,
963
+ current: float,
964
+ ) -> None:
965
+ formatted_candidates = ", ".join(
966
+ (
967
+ f"account={_relative_availability_label(state)} "
968
+ f"remaining_credits={_remaining_secondary_credits(state):.2f} "
969
+ f"remaining_minutes={_relative_availability_remaining_seconds(state, current) / 60.0:.2f} "
970
+ f"score_per_minute={_relative_availability_score_per_minute(raw_score):.6f} "
971
+ f"weight={weight:.8f}"
972
+ )
973
+ for state, weight, raw_score in weighted_candidates
974
+ )
975
+ logger.info("%s%s", _RELATIVE_AVAILABILITY_LOG_PREFIX_TOP_K, formatted_candidates)
976
+
977
+
978
+ def _relative_availability_weighted_candidates(
979
+ available: list[AccountState],
980
+ *,
981
+ current: float,
982
+ power: float,
983
+ top_k: int,
984
+ membership_seed: str | None = None,
985
+ ) -> list[tuple[AccountState, float, float]]:
986
+ raw_scores = [(state, _relative_availability_raw_score(state, current)) for state in available]
987
+ _log_relative_availability_candidate_scores(raw_scores, current=current)
988
+ best_raw_score = max((score for _, score in raw_scores), default=0.0)
989
+ if best_raw_score <= 0.0:
990
+ return []
991
+
992
+ weighted: list[tuple[AccountState, float, float]] = []
993
+ safe_power = power if power > 0.0 else DEFAULT_RELATIVE_AVAILABILITY_POWER
994
+ for state, raw_score in raw_scores:
995
+ normalized_score = raw_score / best_raw_score
996
+ weight = normalized_score**safe_power
997
+ # The floor is an eligibility bound, not a ranking, so it applies to a
998
+ # seeded caller too: a retained thread must not be parked on an account
999
+ # that has fallen far behind the best available one.
1000
+ if weight < RELATIVE_AVAILABILITY_MIN_WEIGHT_FRACTION:
1001
+ continue
1002
+ weighted.append((state, weight, raw_score))
1003
+
1004
+ if not weighted:
1005
+ return []
1006
+
1007
+ if membership_seed is not None:
1008
+ # ``_usage_sort_key`` ends in ``last_selected_at``, so exact ties are
1009
+ # broken by *recency*: each admitted turn advances the winner's
1010
+ # timestamp and ejects it from the top-k on the next turn. Ordering by
1011
+ # the caller's own seed instead is stable turn to turn, and keyed per
1012
+ # thread rather than globally -- otherwise every retained conversation
1013
+ # is handed the same k accounts and the rest of an equally scored pool
1014
+ # never serves a turn.
1015
+ weighted.sort(key=lambda item: _decorrelated_tie_breaker(item[0].account_id, membership_seed))
1016
+ else:
1017
+ weighted.sort(key=lambda item: (-item[1], -item[2], *_usage_sort_key(item[0])))
1018
+ # The ``k`` cut is a *rank* cut over live availability, so a sibling can
1019
+ # stay well within reach and still drop out of it when another account's
1020
+ # usage refreshes. A seeded caller keeps every candidate above the minimum
1021
+ # weight fraction instead: that floor moves only when an account genuinely
1022
+ # falls away from the best, which is a change in the pool rather than a
1023
+ # reshuffle of it.
1024
+ safe_top_k = len(weighted) if membership_seed is not None else max(1, top_k)
1025
+ top_candidates = weighted[:safe_top_k]
1026
+ _log_relative_availability_top_k(top_candidates, current=current)
1027
+ return top_candidates
1028
+
1029
+
1030
+ def _log_relative_availability_winner(
1031
+ winner: AccountState,
1032
+ *,
1033
+ current: float,
1034
+ weight: float | None,
1035
+ raw_score: float,
1036
+ ) -> None:
1037
+ remaining_seconds = _relative_availability_remaining_seconds(winner, current)
1038
+ logger.info(
1039
+ (
1040
+ f"{_RELATIVE_AVAILABILITY_LOG_PREFIX_WINNER}account=%s "
1041
+ "remaining_credits=%.2f remaining_minutes=%.2f score_per_minute=%.6f weight=%s"
1042
+ ),
1043
+ _relative_availability_label(winner),
1044
+ _remaining_secondary_credits(winner),
1045
+ remaining_seconds / 60.0,
1046
+ _relative_availability_score_per_minute(raw_score),
1047
+ f"{weight:.8f}" if weight is not None else "fallback",
1048
+ )
1049
+
1050
+
1051
+ def _select_relative_availability(
1052
+ available: list[AccountState],
1053
+ *,
1054
+ current: float,
1055
+ power: float,
1056
+ top_k: int,
1057
+ deterministic_probe: bool,
1058
+ selection_seed: str | None = None,
1059
+ ) -> AccountState:
1060
+ weighted_candidates = _relative_availability_weighted_candidates(
1061
+ available,
1062
+ current=current,
1063
+ power=power,
1064
+ top_k=top_k,
1065
+ membership_seed=selection_seed,
1066
+ )
1067
+ if not weighted_candidates:
1068
+ winner = (
1069
+ _seeded_least_used(available, selection_seed)
1070
+ if selection_seed is not None
1071
+ else min(available, key=_usage_sort_key)
1072
+ )
1073
+ _log_relative_availability_winner(
1074
+ winner,
1075
+ current=current,
1076
+ weight=None,
1077
+ raw_score=_relative_availability_raw_score(winner, current),
1078
+ )
1079
+ return winner
1080
+ if deterministic_probe:
1081
+ winner, weight, raw_score = weighted_candidates[0]
1082
+ _log_relative_availability_winner(winner, current=current, weight=weight, raw_score=raw_score)
1083
+ return winner
1084
+ states = [state for state, _, _ in weighted_candidates]
1085
+ # Top-k membership is decided by availability alone (above); the recent
1086
+ # error rate only discounts the draw so a penalized account still competes.
1087
+ weights = [weight * _selection_weight_multiplier(state) for state, weight, _ in weighted_candidates]
1088
+ total = sum(weights)
1089
+ if total <= 0.0:
1090
+ winner = (
1091
+ _seeded_least_used(available, selection_seed)
1092
+ if selection_seed is not None
1093
+ else min(available, key=_usage_sort_key)
1094
+ )
1095
+ _log_relative_availability_winner(
1096
+ winner,
1097
+ current=current,
1098
+ weight=None,
1099
+ raw_score=_relative_availability_raw_score(winner, current),
1100
+ )
1101
+ return winner
1102
+ if selection_seed is not None:
1103
+ # Inside the top-k the draw has already been narrowed by availability,
1104
+ # and membership is stable for a seeded caller (see
1105
+ # ``stable_membership``), so the seed picks uniformly among the
1106
+ # survivors. Unlike the capacity draw, these weights are *normalized*
1107
+ # against the current best score: they move whenever any account's
1108
+ # availability moves, including the retained thread's own substitute as
1109
+ # it serves. Weighting the pick by them would put every thread one
1110
+ # admission away from flipping, which is the rotation the seed exists
1111
+ # to prevent -- so here the narrowing carries the availability
1112
+ # preference and the seed only chooses among what survived it.
1113
+ # Zero-weight candidates are dropped as in the capacity draw.
1114
+ winner = _seeded_account(
1115
+ [state for state, weight in zip(states, weights, strict=True) if weight > 0.0],
1116
+ selection_seed,
1117
+ )
1118
+ else:
1119
+ winner = random.choices(states, weights=weights, k=1)[0]
1120
+ for state, weight, raw_score in weighted_candidates:
1121
+ if state.account_id == winner.account_id:
1122
+ _log_relative_availability_winner(winner, current=current, weight=weight, raw_score=raw_score)
1123
+ break
1124
+ return winner
1125
+
1126
+
1127
+ def _seeded_account(pool: list[AccountState], seed: str) -> AccountState:
1128
+ """The seed's choice among candidates the strategy was about to draw from."""
1129
+ return min(pool, key=lambda state: _decorrelated_tie_breaker(state.account_id, seed))
1130
+
1131
+
1132
+ def _seeded_least_used(available: list[AccountState], seed: str) -> AccountState:
1133
+ """The exhausted-pool fallback, made stable for a seeded caller.
1134
+
1135
+ ``min(available, key=_usage_sort_key)`` ends in ``last_selected_at``, so
1136
+ when every candidate is equally spent -- which is exactly when this
1137
+ fallback runs -- being chosen is what loses you the next turn, and the
1138
+ caller's thread rotates through the tied accounts. Seed the tie instead,
1139
+ keeping the usage ordering that decides which accounts are tied at all.
1140
+ """
1141
+ least_used = min(_usage_sort_key(state)[:2] for state in available)
1142
+ return _seeded_account([state for state in available if _usage_sort_key(state)[:2] == least_used], seed)
1143
+
1144
+
1145
+ def _stable_tie_breaker(account_id: str) -> str:
1146
+ return hashlib.sha256(account_id.encode("utf-8")).hexdigest()
1147
+
1148
+
1149
+ # ---------------------------------------------------------------------------
1150
+ # Cross-replica round-robin decorrelation
1151
+ #
1152
+ # ``round_robin`` orders candidates by planner cost, then least-recently-
1153
+ # selected time, then a stable final tie-break. In a multi-replica deployment
1154
+ # every replica shares one account set and computes the identical key, so an
1155
+ # *exact* tie on the primary keys (for example a cold start where every account
1156
+ # is never-selected, or several accounts whose ``last_selected_at`` reset to
1157
+ # ``0.0``) makes every replica break the tie toward the same account -- herding
1158
+ # load onto it. Mixing a stable per-replica salt into the final tie-break
1159
+ # decorrelates that choice so peers spread across equally-good accounts, without
1160
+ # touching the primary ordering.
1161
+ # ---------------------------------------------------------------------------
1162
+
1163
+ _configured_replica_salt: str | None = None
1164
+ _process_replica_salt: str | None = None
1165
+
1166
+
1167
+ def configure_replica_salt(salt: str | None) -> None:
1168
+ """Set the process-wide replica salt used to decorrelate round-robin ties.
1169
+
1170
+ Called once during proxy start-up with the replica's stable identity (the
1171
+ HTTP responses-session bridge instance id). An empty or ``None`` value
1172
+ clears the override and restores the lazily-resolved host default. The salt
1173
+ is process-stable by design: it must not change between selections so a
1174
+ replica breaks a given tie the same way every time.
1175
+ """
1176
+ global _configured_replica_salt
1177
+ normalized = salt.strip() if salt else ""
1178
+ _configured_replica_salt = normalized or None
1179
+
1180
+
1181
+ def _default_replica_salt() -> str:
1182
+ """Return the cached host-identity fallback salt.
1183
+
1184
+ Resolved once per process so the salt never varies between calls within a
1185
+ replica, keeping single-replica selection deterministic. Matches the HTTP
1186
+ bridge instance-id default (the host name) so an unconfigured process still
1187
+ decorrelates by pod/host.
1188
+ """
1189
+ global _process_replica_salt
1190
+ if _process_replica_salt is None:
1191
+ hostname = socket.gethostname().strip()
1192
+ _process_replica_salt = hostname or "codex-lb"
1193
+ return _process_replica_salt
1194
+
1195
+
1196
+ def _effective_replica_salt(explicit: str | None) -> str:
1197
+ if explicit is not None and explicit.strip():
1198
+ return explicit.strip()
1199
+ if _configured_replica_salt is not None:
1200
+ return _configured_replica_salt
1201
+ return _default_replica_salt()
1202
+
1203
+
1204
+ def _decorrelated_tie_breaker(account_id: str, salt: str) -> str:
1205
+ """Keyed hash of ``account_id`` under a per-replica ``salt``.
1206
+
1207
+ Deterministic for a given ``(salt, account_id)`` pair -- a replica always
1208
+ breaks the same tie the same way -- while distinct salts yield independent
1209
+ orderings across replicas.
1210
+ """
1211
+ return hashlib.sha256(f"{salt}\x00{account_id}".encode("utf-8")).hexdigest()
1212
+
1213
+
1214
+ def _configured_capacity_credits(state: AccountState) -> float:
1215
+ if state.capacity_credits is not None and state.capacity_credits > 0:
1216
+ return state.capacity_credits
1217
+ return _fallback_secondary_capacity_credits(state.plan_type)
1218
+
1219
+
1220
+ def _sequential_drain_sort_key(state: AccountState) -> tuple[float, str, str]:
1221
+ return (
1222
+ _configured_capacity_credits(state),
1223
+ _stable_tie_breaker(state.account_id),
1224
+ state.account_id,
1225
+ )
1226
+
1227
+
1228
+ def _weekly_reset_timestamp(state: AccountState, current: float) -> float:
1229
+ if state.secondary_reset_at is not None and float(state.secondary_reset_at) > current:
1230
+ return float(state.secondary_reset_at)
1231
+ if state.reset_at is not None and float(state.reset_at) > current:
1232
+ return float(state.reset_at)
1233
+ return float("inf")
1234
+
1235
+
1236
+ def _reset_drain_sort_key(state: AccountState, current: float) -> tuple[int, float, float, float, str, str]:
1237
+ primary_remaining = 100.0 - (state.used_percent if state.used_percent is not None else 0.0)
1238
+ secondary_remaining = 100.0 - (
1239
+ state.secondary_used_percent
1240
+ if state.secondary_used_percent is not None
1241
+ else state.used_percent
1242
+ if state.used_percent is not None
1243
+ else 0.0
1244
+ )
1245
+ reset_at = _weekly_reset_timestamp(state, current)
1246
+ reset_bucket_days = (
1247
+ UNKNOWN_RESET_BUCKET_DAYS if reset_at == float("inf") else max(0, int((reset_at - current) // SECONDS_PER_DAY))
1248
+ )
1249
+ return (
1250
+ reset_bucket_days,
1251
+ -max(0.0, primary_remaining),
1252
+ -max(0.0, secondary_remaining),
1253
+ reset_at,
1254
+ _stable_tie_breaker(state.account_id),
1255
+ state.account_id,
1256
+ )
1257
+
1258
+
1259
+ def _capacity_probe_sort_key_with_cost(
1260
+ state: AccountState,
1261
+ routing_costs: RoutingCostsByAccount | None,
1262
+ ) -> tuple[float, float, float, float, float, str]:
1263
+ secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
1264
+ return (
1265
+ _planner_cost(state, routing_costs),
1266
+ -_remaining_secondary_credits(state),
1267
+ secondary_used,
1268
+ primary_used,
1269
+ last_selected,
1270
+ account_id,
1271
+ )
1272
+
1273
+
1274
+ def _lowest_planner_cost_candidates(
1275
+ available: list[AccountState],
1276
+ routing_costs: RoutingCostsByAccount | None,
1277
+ ) -> list[AccountState]:
1278
+ if not routing_costs:
1279
+ return available
1280
+ lowest_cost = min(_planner_cost(state, routing_costs) for state in available)
1281
+ return [state for state in available if _planner_cost(state, routing_costs) == lowest_cost]
1282
+
1283
+
1284
+ def _select_capacity_weighted(
1285
+ available: list[AccountState],
1286
+ *,
1287
+ selection_seed: str | None = None,
1288
+ ) -> AccountState:
1289
+ """Select an account with probability proportional to remaining secondary credits."""
1290
+ weights = [_remaining_secondary_credits(s) * _selection_weight_multiplier(s) for s in available]
1291
+ total = sum(weights)
1292
+ if total <= 0.0:
1293
+ # All accounts exhausted — fall back to deterministic usage-weighted
1294
+ if selection_seed is not None:
1295
+ return _seeded_least_used(available, selection_seed)
1296
+ return min(available, key=_usage_sort_key)
1297
+ if selection_seed is not None:
1298
+ # A zero-weight account is one the draw could never have returned while
1299
+ # a positive-weight sibling existed -- an account whose effective
1300
+ # secondary credits are spent. The seed must not readmit it.
1301
+ #
1302
+ # The *magnitudes* are deliberately dropped here, and only here. They
1303
+ # are live: a usage refresh, an elapsed reset, or an error-rate update
1304
+ # moves them without anything about eligibility changing, so a pick
1305
+ # weighted by them puts every seeded thread one refresh away from
1306
+ # flipping -- which is the fan-out this seed exists to prevent. Ordinary
1307
+ # (unseeded) traffic keeps the full weighted draw, so the pool-level
1308
+ # capacity balance is unchanged; a retained thread simply lands
1309
+ # uniformly among the accounts that draw had already accepted.
1310
+ return _seeded_account(
1311
+ [state for state, weight in zip(available, weights, strict=True) if weight > 0.0],
1312
+ selection_seed,
1313
+ )
1314
+ return random.choices(available, weights=weights, k=1)[0]
1315
+
1316
+
1317
+ def _selection_weight_multiplier(state: AccountState) -> float:
1318
+ """Clamp the balancer-supplied draw multiplier so a bad value can never
1319
+ negate or unbound a weight."""
1320
+ multiplier = state.selection_weight_multiplier
1321
+ if multiplier != multiplier or multiplier < 0.0: # NaN or negative
1322
+ return 1.0
1323
+ return min(multiplier, 1.0)
1324
+
1325
+
1326
+ def _fill_first_sort_key(state: AccountState) -> tuple[float, float, str]:
1327
+ primary_used = state.used_percent if state.used_percent is not None else 0.0
1328
+ secondary_used = state.secondary_used_percent if state.secondary_used_percent is not None else 0.0
1329
+ return -primary_used, -secondary_used, state.account_id
1330
+
1331
+
1332
+ def _select_fill_first(available: list[AccountState]) -> AccountState:
1333
+ """Pick the eligible account with the highest primary 5h ``used_percent``.
1334
+
1335
+ Deterministic. ``None`` ``used_percent`` is treated as ``0.0`` so a
1336
+ freshly refreshed account ties with an unknown-usage account.
1337
+
1338
+ When two or more candidates share the same primary ``used_percent``,
1339
+ the account with the **higher** secondary (weekly) ``used_percent`` is
1340
+ preferred — i.e. the one with the least remaining weekly capacity.
1341
+ This drains the most-saturated account first and preserves the freshest
1342
+ one for later cycles, matching operator intent for "fill first" behavior.
1343
+ ``account_id`` ascending is the final stable tiebreaker.
1344
+
1345
+ Drained accounts are only reachable here when no healthy or probing
1346
+ account exists, via the existing ``effective_pool`` ladder; this helper
1347
+ introduces no new bypass.
1348
+ """
1349
+ return min(available, key=_fill_first_sort_key)
1350
+
1351
+
1352
+ # Minimum persisted cooldown for a metadata-free 429. The error-count backoff
1353
+ # starts near 0.2s, which is meaningless as a cross-replica cooldown: a peer
1354
+ # replica reading the row would flip it back to ACTIVE almost immediately. The
1355
+ # floor applies only to the persisted ``reset_at`` deadline written on the
1356
+ # backoff fallback path — Retry-After hints are persisted rounded up to the
1357
+ # next whole second (persistence truncates ``reset_at`` via ``int()``, so a
1358
+ # short or fractional hint must not round down to an already-elapsed
1359
+ # deadline), upstream reset metadata is persisted verbatim, and the local
1360
+ # ``cooldown_until`` keeps the raw backoff.
1361
+ RATE_LIMITED_MIN_COOLDOWN_SECONDS = 30.0
1362
+
1363
+
1364
+ def handle_rate_limit(state: AccountState, error: UpstreamError) -> None:
1365
+ now = time.time()
1366
+ state.status = AccountStatus.RATE_LIMITED
1367
+ state.error_count += 1
1368
+ state.last_error_at = now
1369
+ state.blocked_at = now
1370
+
1371
+ reset_at = _extract_reset_at(error, now=now)
1372
+
1373
+ message = error.get("message")
1374
+ delay = parse_retry_after(message) if message else None
1375
+ if delay is None:
1376
+ delay = backoff_seconds(state.error_count)
1377
+ persisted_delay = max(delay, RATE_LIMITED_MIN_COOLDOWN_SECONDS)
1378
+ else:
1379
+ persisted_delay = delay
1380
+ state.cooldown_until = now + delay
1381
+
1382
+ if reset_at is not None:
1383
+ state.reset_at = reset_at
1384
+ else:
1385
+ # Persist the resolved cooldown deadline so replicas sharing the
1386
+ # database honor it instead of flipping the account back to ACTIVE
1387
+ # from their own (empty) runtime state. The write rides the existing
1388
+ # ``mark_rate_limit -> _persist_state`` status update. Round the
1389
+ # deadline UP to a whole second: ``_persist_state`` truncates via
1390
+ # ``int()``, so a short or fractional Retry-After hint (for example
1391
+ # "500ms", or "1s" near a second boundary) would otherwise persist a
1392
+ # deadline that is already elapsed for peer replicas, dropping the
1393
+ # cooldown entirely.
1394
+ state.reset_at = float(math.ceil(now + persisted_delay))
1395
+
1396
+
1397
+ QUOTA_EXCEEDED_COOLDOWN_SECONDS = 120.0
1398
+
1399
+ # Upper bound for the user-visible "Try again in {N}s" hint that
1400
+ # ``select_account`` surfaces when zero candidates are selectable. The clamp
1401
+ # protects clients from waiting the worst-case persisted ``reset_at`` after
1402
+ # OpenAI-side reset events that propagate lazily through ``/wham/usage`` (see
1403
+ # https://github.com/Soju06/codex-lb/issues/676). codex-lb's background usage
1404
+ # refresh runs every ``USAGE_REFRESH_INTERVAL_SECONDS`` (60 s) and the
1405
+ # per-status cooldowns are 120s, so a 300s ceiling lets clients reattempt
1406
+ # inside the auto-recovery window. The underlying ``AccountState.reset_at``
1407
+ # and ``AccountState.cooldown_until`` fields are not clamped.
1408
+ SELECTOR_RETRY_HINT_MAX_SECONDS = 300
1409
+
1410
+
1411
+ def _format_retry_hint(wait_seconds: float) -> str:
1412
+ capped = min(max(0.0, wait_seconds), float(SELECTOR_RETRY_HINT_MAX_SECONDS))
1413
+ return f"Rate limit exceeded. Try again in {capped:.0f}s"
1414
+
1415
+
1416
+ def handle_quota_exceeded(state: AccountState, error: UpstreamError) -> None:
1417
+ now = time.time()
1418
+ state.status = AccountStatus.QUOTA_EXCEEDED
1419
+ state.used_percent = 100.0
1420
+ state.blocked_at = now
1421
+ state.cooldown_until = now + QUOTA_EXCEEDED_COOLDOWN_SECONDS
1422
+
1423
+ reset_at = _extract_reset_at(error, now=now)
1424
+ if reset_at is not None:
1425
+ state.reset_at = reset_at
1426
+ else:
1427
+ state.reset_at = int(now + 3600)
1428
+
1429
+
1430
+ def handle_permanent_failure(state: AccountState, error_code: str) -> None:
1431
+ state.status = account_status_for_permanent_failure(error_code)
1432
+ state.deactivation_reason = PERMANENT_FAILURE_CODES.get(
1433
+ error_code,
1434
+ f"Authentication failed: {error_code}",
1435
+ )
1436
+ state.blocked_at = None
1437
+
1438
+
1439
+ def account_status_for_permanent_failure(error_code: str) -> AccountStatus:
1440
+ if error_code in REAUTH_REQUIRED_FAILURE_CODES:
1441
+ return AccountStatus.REAUTH_REQUIRED
1442
+ return AccountStatus.DEACTIVATED
1443
+
1444
+
1445
+ FailoverAction = Literal["failover_next", "retry_same_account", "surface"]
1446
+
1447
+ # Owner-bound burst 429 (a code-less upstream HTTP 429 burst/concurrency
1448
+ # rejection on a request that cannot move to another account): bounded
1449
+ # same-account backoff before the original rejection is surfaced. Module
1450
+ # constants on purpose -- the Settings ratchet is full and this is a transport
1451
+ # invariant, not an operator knob. Waits are 1 s, 2 s, 4 s (upstream
1452
+ # ``Retry-After`` is a floor), never above ``BURST_SAME_ACCOUNT_MAX_WAIT_SECONDS``.
1453
+ BURST_SAME_ACCOUNT_MAX_RETRIES = 3
1454
+ BURST_SAME_ACCOUNT_BASE_SECONDS = 1.0
1455
+ BURST_SAME_ACCOUNT_MAX_WAIT_SECONDS = 10.0
1456
+ # ``Retry-After`` stamped on a surfaced burst 429 that carried none upstream;
1457
+ # matches ``app.core.resilience.overload.LOCAL_OVERLOAD_RETRY_AFTER_SECONDS``.
1458
+ BURST_SURFACE_RETRY_AFTER_SECONDS = 5
1459
+
1460
+
1461
+ def burst_same_account_backoff_seconds(retry_index: int, *, retry_after_seconds: float | None) -> float:
1462
+ """Deterministic wait before same-account burst retry ``retry_index`` (1-based).
1463
+
1464
+ ``min(MAX_WAIT, max(retry_after_seconds or 0, BASE * 2 ** (retry_index - 1)))``:
1465
+ exponential from ``BURST_SAME_ACCOUNT_BASE_SECONDS`` with the upstream
1466
+ ``Retry-After`` (when present and positive) acting as a floor. No jitter, so
1467
+ the caller's timing seam (``scheduler.sleep``) fully owns the wait.
1468
+ """
1469
+ exponential = BURST_SAME_ACCOUNT_BASE_SECONDS * (2 ** (max(retry_index, 1) - 1))
1470
+ floor = float(retry_after_seconds) if retry_after_seconds is not None and retry_after_seconds > 0 else 0.0
1471
+ return min(BURST_SAME_ACCOUNT_MAX_WAIT_SECONDS, max(floor, exponential))
1472
+
1473
+
1474
+ def failover_decision(
1475
+ *,
1476
+ failure_class: FailureClass,
1477
+ downstream_visible: bool,
1478
+ candidates_remaining: int,
1479
+ owner_bound: bool = False,
1480
+ same_account_retry_available: bool = False,
1481
+ ) -> FailoverAction:
1482
+ """Decide how a pre-visible upstream failure is handled.
1483
+
1484
+ ``owner_bound`` means the request cannot move to another account (dispatched
1485
+ account-bound payload, required previous-response / turn-state / file
1486
+ owner). Such a request never fails over -- ``failover_next`` would be a
1487
+ lie -- so it either retries the same account (when the caller reports a
1488
+ bounded same-account retry is still available) or surfaces the failure.
1489
+ """
1490
+ if downstream_visible:
1491
+ return "surface"
1492
+ if owner_bound:
1493
+ return (
1494
+ "retry_same_account"
1495
+ if same_account_retry_available and failure_class != "account_unavailable"
1496
+ else "surface"
1497
+ )
1498
+ if candidates_remaining <= 0:
1499
+ return "surface"
1500
+ if failure_class in ("rate_limit", "quota", "retryable_transient", "account_unavailable"):
1501
+ return "failover_next"
1502
+ return "surface"
1503
+
1504
+
1505
+ def plausible_rate_limit_reset_at(
1506
+ reset_at: int | float | None,
1507
+ *,
1508
+ now: float | None = None,
1509
+ allow_rounding_slack: bool = True,
1510
+ ) -> float | None:
1511
+ """Return a finite near-future reset deadline, or ``None`` when invalid."""
1512
+
1513
+ if reset_at is None or isinstance(reset_at, bool):
1514
+ return None
1515
+ current = time.time() if now is None else now
1516
+ if isinstance(reset_at, float) and not math.isfinite(reset_at):
1517
+ return None
1518
+ horizon = RATE_LIMIT_RESET_MAX_HORIZON_SECONDS
1519
+ if allow_rounding_slack:
1520
+ # Persisted deadlines are whole seconds; ceil may move an otherwise
1521
+ # valid raw deadline just beyond the metadata horizon.
1522
+ horizon += RATE_LIMIT_RESET_ROUNDING_TOLERANCE_SECONDS
1523
+ # Keep this comparison ahead of float conversion: Python integers can be
1524
+ # arbitrarily large, while converting malformed metadata can overflow.
1525
+ if reset_at <= current or reset_at > current + horizon:
1526
+ return None
1527
+ return float(reset_at)
1528
+
1529
+
1530
+ def _extract_reset_at(error: UpstreamError, *, now: float) -> int | None:
1531
+ reset_at = plausible_rate_limit_reset_at(
1532
+ error.get("resets_at"),
1533
+ now=now,
1534
+ allow_rounding_slack=False,
1535
+ )
1536
+ if reset_at is not None:
1537
+ rounded_reset_at = int(math.ceil(reset_at))
1538
+ if plausible_rate_limit_reset_at(rounded_reset_at, now=now) is not None:
1539
+ return rounded_reset_at
1540
+ return None
1541
+
1542
+ reset_in = error.get("resets_in_seconds")
1543
+ if reset_in is None or isinstance(reset_in, bool):
1544
+ return None
1545
+ if isinstance(reset_in, float) and not math.isfinite(reset_in):
1546
+ return None
1547
+ if reset_in <= 0 or reset_in > RATE_LIMIT_RESET_MAX_HORIZON_SECONDS:
1548
+ return None
1549
+ rounded_reset_at = int(math.ceil(now + float(reset_in)))
1550
+ if plausible_rate_limit_reset_at(rounded_reset_at, now=now) is not None:
1551
+ return rounded_reset_at
1552
+ return None
1553
+
1554
+
1555
+ def evaluate_health_tier(
1556
+ state: AccountState,
1557
+ *,
1558
+ now: float | None = None,
1559
+ drain_entered_at: float | None = None,
1560
+ probe_success_streak: int = 0,
1561
+ drain_primary_threshold_pct: float = DRAIN_PRIMARY_THRESHOLD_PCT,
1562
+ drain_secondary_threshold_pct: float = DRAIN_SECONDARY_THRESHOLD_PCT,
1563
+ drain_error_window_seconds: float = DRAIN_ERROR_WINDOW_SECONDS,
1564
+ drain_error_count_threshold: int = DRAIN_ERROR_COUNT_THRESHOLD,
1565
+ probe_quiet_seconds: float = PROBE_QUIET_SECONDS,
1566
+ probe_success_streak_required: int = PROBE_SUCCESS_STREAK_REQUIRED,
1567
+ ) -> int:
1568
+ current = now or time.time()
1569
+
1570
+ if state.status in (
1571
+ AccountStatus.RATE_LIMITED,
1572
+ AccountStatus.QUOTA_EXCEEDED,
1573
+ AccountStatus.PAUSED,
1574
+ AccountStatus.DEACTIVATED,
1575
+ ):
1576
+ return state.health_tier
1577
+
1578
+ should_drain = False
1579
+
1580
+ if state.used_percent is not None and state.used_percent >= drain_primary_threshold_pct:
1581
+ should_drain = True
1582
+
1583
+ if state.secondary_used_percent is not None and state.secondary_used_percent >= drain_secondary_threshold_pct:
1584
+ should_drain = True
1585
+
1586
+ if (
1587
+ state.error_count >= drain_error_count_threshold
1588
+ and state.last_error_at is not None
1589
+ and current - state.last_error_at < drain_error_window_seconds
1590
+ ):
1591
+ should_drain = True
1592
+
1593
+ current_tier = state.health_tier
1594
+
1595
+ if current_tier == HEALTH_TIER_HEALTHY:
1596
+ return HEALTH_TIER_DRAINING if should_drain else HEALTH_TIER_HEALTHY
1597
+
1598
+ if current_tier == HEALTH_TIER_DRAINING:
1599
+ if should_drain:
1600
+ return HEALTH_TIER_DRAINING
1601
+ if drain_entered_at is not None and current - drain_entered_at >= probe_quiet_seconds:
1602
+ return HEALTH_TIER_PROBING
1603
+ return HEALTH_TIER_DRAINING
1604
+
1605
+ if current_tier == HEALTH_TIER_PROBING:
1606
+ if should_drain:
1607
+ return HEALTH_TIER_DRAINING
1608
+ if probe_success_streak >= probe_success_streak_required:
1609
+ return HEALTH_TIER_HEALTHY
1610
+ return HEALTH_TIER_PROBING
1611
+
1612
+ return HEALTH_TIER_HEALTHY