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,2122 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ import re
7
+ import time
8
+ from collections import deque
9
+ from collections.abc import Awaitable, Callable, Coroutine, Mapping
10
+ from contextvars import ContextVar, Token
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Any, Literal, NoReturn, Protocol, cast
14
+
15
+ import anyio
16
+
17
+ from app.core.auth.refresh import RefreshError, is_transient_refresh_contention, refresh_contention_kind
18
+ from app.core.balancer.types import UpstreamError
19
+ from app.core.clients.proxy import CodexControlRequestPrivacyPolicy, ProxyResponseError
20
+ from app.core.clients.proxy_websocket import (
21
+ UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL,
22
+ UpstreamWebSocket,
23
+ )
24
+ from app.core.clock import REAL_CLOCK, Clock, Scheduler
25
+ from app.core.config.settings import get_settings
26
+ from app.core.errors import OpenAIErrorEnvelope, OpenAIErrorParam, openai_error
27
+ from app.core.openai.model_registry import get_model_registry
28
+ from app.core.openai.models import OpenAIEvent
29
+ from app.core.openai.parsing import classify_event_type
30
+ from app.core.plan_types import account_plan_matches_allowed
31
+ from app.core.resilience.network_recovery import PROCESS_NETWORK_UNAVAILABLE_CODE
32
+ from app.core.resilience.overload import is_local_overload_error_code
33
+ from app.core.types import JsonValue
34
+ from app.core.upstream_proxy import ResolvedUpstreamRoute
35
+ from app.core.utils.locks import fast_lock
36
+ from app.core.utils.sse import sse_event_type_from_block
37
+ from app.db.models import Account, StickySessionKind
38
+ from app.modules.api_keys.service import (
39
+ ApiKeyData,
40
+ ApiKeyRequestUsageBudget,
41
+ ApiKeyUsageReservationData,
42
+ )
43
+ from app.modules.proxy.affinity import _AffinityPolicy
44
+ from app.modules.proxy.affinity_observation import AffinityObservation
45
+ from app.modules.proxy.helpers import _normalize_error_code, _parse_openai_error
46
+ from app.modules.proxy.load_balancer import (
47
+ AccountLease,
48
+ AccountSelection,
49
+ CatalogOmissionQuotaAdmission,
50
+ )
51
+ from app.modules.proxy.tool_call_dedupe import ToolCallDedupeKey
52
+ from app.modules.proxy.work_admission import AdmissionLease
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+
57
+ _REQUEST_TRANSPORT_HTTP = "http"
58
+ _REQUEST_TRANSPORT_WEBSOCKET = "websocket"
59
+ _REASONING_SUMMARY_BLANK_HTML_COMMENT_RE = re.compile(r"(?m)^[ \t]*<!--\s*-->[ \t]*(?:\r?\n|\Z)")
60
+ _TTFT_EVENT_TYPES = frozenset(
61
+ {
62
+ "response.output_text.delta",
63
+ "response.refusal.delta",
64
+ "response.reasoning_text.delta",
65
+ }
66
+ )
67
+ _TTFT_TOOL_DELTA_EVENT_TYPES = frozenset(
68
+ {
69
+ "response.function_call_arguments.delta",
70
+ "response.output_tool_call.delta",
71
+ "response.custom_tool_call_input.delta",
72
+ }
73
+ )
74
+ _TTFT_REASONING_EVENT_TYPES = frozenset(
75
+ {"response.reasoning_summary_text.delta", "response.reasoning_summary_text.done"}
76
+ )
77
+ _PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE = {
78
+ "function_call": "function_call_output",
79
+ "custom_tool_call": "custom_tool_call_output",
80
+ "apply_patch_call": "apply_patch_call_output",
81
+ }
82
+ _PENDING_TOOL_CALL_ITEM_TYPES = frozenset(_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE)
83
+ _PENDING_TOOL_CALL_OUTPUT_ITEM_TYPES = frozenset(_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE.values())
84
+ _TTFT_OUTPUT_ITEM_TYPES = _PENDING_TOOL_CALL_ITEM_TYPES - {"function_call"}
85
+ # Upstream ``response.*`` events that prove the model already ran for a turn.
86
+ # Both relay surfaces flip ``upstream_model_output_seen`` on them; in the
87
+ # Responses protocol the first one is always ``response.output_item.added``.
88
+ _MODEL_OUTPUT_EVENT_TYPES = frozenset(
89
+ {
90
+ "response.output_item.added",
91
+ "response.output_item.done",
92
+ "response.output_text.delta",
93
+ "response.refusal.delta",
94
+ "response.reasoning_text.delta",
95
+ "response.reasoning_summary_text.delta",
96
+ "response.reasoning_summary_text.done",
97
+ "response.function_call_arguments.delta",
98
+ "response.output_tool_call.delta",
99
+ }
100
+ )
101
+ _WEBSOCKET_FULL_REPLAY_WAIT_MIN_ITEMS = 20
102
+ _WEBSOCKET_FULL_REPLAY_WAIT_POLL_SECONDS = 0.05
103
+ _HARD_HTTP_BRIDGE_AFFINITY_KINDS = frozenset(
104
+ {
105
+ "turn_state_header",
106
+ "session_header",
107
+ "thread_header",
108
+ "internal_unanchored_parallel",
109
+ "internal_model_parallel",
110
+ "internal_request_parallel",
111
+ }
112
+ )
113
+ _ACCOUNT_SELECTION_RECOVERY_MIN_SLEEP_SECONDS = 1.0
114
+ _HARD_AFFINITY_RECOVERY_SLEEP_SECONDS = 2.0
115
+ _ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS = 30.0
116
+ _ACCOUNT_SELECTION_RECOVERY_MAX_SLEEP_SECONDS = 300.0
117
+ _ACCOUNT_SELECTION_RECOVERY_HEARTBEAT_SECONDS = 10.0
118
+ _ACCOUNT_SELECTION_RETRY_HINT_RE = re.compile(r"try again in\s+([0-9]+(?:\.[0-9]+)?)s", re.IGNORECASE)
119
+ _LOCAL_ACCOUNT_CAP_ERROR_CODES = frozenset(
120
+ {"account_response_create_cap", "account_stream_cap", "api_key_stream_fair_share"}
121
+ )
122
+ _ACCOUNT_MODEL_UNSUPPORTED_ERROR_CODE = "account_model_unsupported"
123
+ _PROPAGATED_CAPACITY_STARTUP_WAIT: ContextVar[asyncio.Event | None] = ContextVar(
124
+ "propagated_capacity_startup_wait",
125
+ default=None,
126
+ )
127
+ _PROPAGATED_CAPACITY_STARTUP_READY: ContextVar[asyncio.Event | None] = ContextVar(
128
+ "propagated_capacity_startup_ready",
129
+ default=None,
130
+ )
131
+ _PROPAGATED_RESPONSES_SERVICE_CLEANUP_READY: ContextVar[asyncio.Event | None] = ContextVar(
132
+ "propagated_responses_service_cleanup_ready",
133
+ default=None,
134
+ )
135
+ _PROPAGATED_RESPONSES_OWNER_FORWARD_DISPATCHED: ContextVar[asyncio.Event | None] = ContextVar(
136
+ "propagated_responses_owner_forward_dispatched",
137
+ default=None,
138
+ )
139
+ _PROPAGATED_RESPONSES_OWNER_FORWARD_REJECTED: ContextVar[asyncio.Event | None] = ContextVar(
140
+ "propagated_responses_owner_forward_rejected",
141
+ default=None,
142
+ )
143
+
144
+
145
+ def _strip_blank_html_comment_lines(text: str) -> str:
146
+ terminal_match = None
147
+ for match in _REASONING_SUMMARY_BLANK_HTML_COMMENT_RE.finditer(text):
148
+ if match.end() == len(text):
149
+ terminal_match = match
150
+ cleaned, count = _REASONING_SUMMARY_BLANK_HTML_COMMENT_RE.subn("", text)
151
+ if count == 0:
152
+ return text
153
+ if terminal_match is not None:
154
+ return cleaned.rstrip("\r\n")
155
+ return cleaned
156
+
157
+
158
+ def _reasoning_summary_delta_key(payload: Mapping[str, JsonValue]) -> tuple[str | None, int | None, int | None]:
159
+ item_id = payload.get("item_id")
160
+ output_index = payload.get("output_index")
161
+ summary_index = payload.get("summary_index")
162
+ return (
163
+ item_id if isinstance(item_id, str) else None,
164
+ output_index if isinstance(output_index, int) else None,
165
+ summary_index if isinstance(summary_index, int) else None,
166
+ )
167
+
168
+
169
+ def _could_be_blank_html_comment_line(text: str) -> bool:
170
+ candidate = text.rsplit("\n", 1)[-1].lstrip(" \t")
171
+ if not candidate:
172
+ return False
173
+ if not candidate.startswith("<!--"):
174
+ return "<!--".startswith(candidate)
175
+ remainder = candidate[4:].lstrip()
176
+ if not remainder.startswith("-->"):
177
+ return "-->".startswith(remainder)
178
+ return not remainder[3:].strip(" \t\r")
179
+
180
+
181
+ def _is_reasoning_summary_interleavable_event(event_type: JsonValue | None) -> bool:
182
+ return isinstance(event_type, str) and (event_type == "response.in_progress" or event_type.startswith("codex."))
183
+
184
+
185
+ @dataclass(slots=True)
186
+ class _TTFTReasoningDeltaState:
187
+ text: str
188
+ visible_at: float | None = None
189
+
190
+
191
+ def _visible_reasoning_prefix_before_blank_comment_candidate(text: str) -> str:
192
+ if "\n" not in text:
193
+ return ""
194
+ prefix, _candidate = text.rsplit("\n", 1)
195
+ return _strip_blank_html_comment_lines(prefix).strip()
196
+
197
+
198
+ def _finalize_ttft_reasoning_deltas(
199
+ pending_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState],
200
+ *,
201
+ now: float,
202
+ ) -> float | None:
203
+ # ``now`` is the caller's owner-clock sample; it stamps every pending
204
+ # reasoning delta that became visible without a stamp of its own.
205
+ visible_at_values: list[float] = []
206
+ for pending in pending_reasoning_deltas.values():
207
+ if not _strip_blank_html_comment_lines(pending.text):
208
+ continue
209
+ if pending.visible_at is not None:
210
+ visible_at_values.append(pending.visible_at)
211
+ continue
212
+ visible_at_values.append(now)
213
+ pending_reasoning_deltas.clear()
214
+ return min(visible_at_values) if visible_at_values else None
215
+
216
+
217
+ def _ttft_event_visible_at(
218
+ event_type: str | None,
219
+ payload: dict[str, JsonValue] | None,
220
+ pending_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState] | None = None,
221
+ *,
222
+ now: float,
223
+ ) -> float | None:
224
+ # ``now`` is the caller's owner-clock sample (``clock_for(owner).monotonic()``);
225
+ # it stamps every visible_at value this event makes visible.
226
+ pending = pending_reasoning_deltas if pending_reasoning_deltas is not None else {}
227
+ event_key = _reasoning_summary_delta_key(payload) if payload is not None else (None, None, None)
228
+ if (
229
+ pending
230
+ and not _is_reasoning_summary_interleavable_event(event_type)
231
+ and not (event_type in _TTFT_REASONING_EVENT_TYPES and event_key in pending)
232
+ ):
233
+ visible_at = _finalize_ttft_reasoning_deltas(pending, now=now)
234
+ if visible_at is not None:
235
+ return visible_at
236
+ if event_type == "response.reasoning_summary_text.delta":
237
+ delta = payload.get("delta") if payload is not None else None
238
+ if not isinstance(delta, str):
239
+ return None
240
+ previous = pending.pop(event_key, None)
241
+ combined = (previous.text if previous is not None else "") + delta
242
+ cleaned = _strip_blank_html_comment_lines(combined)
243
+ if cleaned != combined:
244
+ if not cleaned:
245
+ return None
246
+ return previous.visible_at if previous is not None and previous.visible_at is not None else now
247
+ if _could_be_blank_html_comment_line(combined):
248
+ visible_at = None if previous is None else previous.visible_at
249
+ if visible_at is None and _visible_reasoning_prefix_before_blank_comment_candidate(combined):
250
+ visible_at = now
251
+ pending[event_key] = _TTFTReasoningDeltaState(combined, visible_at=visible_at)
252
+ return None
253
+ if not combined:
254
+ return None
255
+ return previous.visible_at if previous is not None and previous.visible_at is not None else now
256
+ if event_type == "response.reasoning_summary_text.done" and event_key in pending:
257
+ visible_at = _finalize_ttft_reasoning_deltas({event_key: pending.pop(event_key)}, now=now)
258
+ return visible_at
259
+ if event_type in _TTFT_TOOL_DELTA_EVENT_TYPES:
260
+ if payload is None:
261
+ return None
262
+ has_visible_part = any(
263
+ isinstance(payload.get(key), str) and bool(payload[key]) for key in ("delta", "arguments")
264
+ )
265
+ return now if has_visible_part else None
266
+ if event_type in _TTFT_EVENT_TYPES:
267
+ delta = payload.get("delta") if payload is not None else None
268
+ return now if isinstance(delta, str) and bool(delta) else None
269
+ if event_type not in {"response.output_item.added", "response.output_item.done"} or not isinstance(payload, dict):
270
+ return None
271
+ item = payload.get("item")
272
+ if not isinstance(item, dict) or item.get("type") not in _TTFT_OUTPUT_ITEM_TYPES:
273
+ return None
274
+ if item.get("type") == "custom_tool_call":
275
+ meaningful = any(isinstance(item.get(key), str) and bool(item[key]) for key in ("input", "arguments"))
276
+ else:
277
+ meaningful = any(item.get(key) not in (None, "", {}) for key in ("operation", "patch", "input"))
278
+ return now if meaningful else None
279
+
280
+
281
+ def _is_ttft_event(
282
+ event_type: str | None,
283
+ payload: dict[str, JsonValue] | None,
284
+ pending_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState] | None = None,
285
+ *,
286
+ now: float,
287
+ ) -> bool:
288
+ return _ttft_event_visible_at(event_type, payload, pending_reasoning_deltas, now=now) is not None
289
+
290
+
291
+ def _ttft_latency_ms_from_visible_at(visible_at: float | None, started_at: float) -> int | None:
292
+ return None if visible_at is None else max(0, int((visible_at - started_at) * 1000))
293
+
294
+
295
+ def _ttft_event_latency_ms(
296
+ event_type: str | None,
297
+ payload: dict[str, JsonValue] | None,
298
+ pending_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState],
299
+ started_at: float,
300
+ *,
301
+ now: float,
302
+ ) -> int | None:
303
+ return _ttft_latency_ms_from_visible_at(
304
+ _ttft_event_visible_at(event_type, payload, pending_reasoning_deltas, now=now), started_at
305
+ )
306
+
307
+
308
+ def _finalize_ttft_latency_ms(
309
+ pending_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState],
310
+ started_at: float,
311
+ *,
312
+ now: float,
313
+ ) -> int | None:
314
+ return _ttft_latency_ms_from_visible_at(
315
+ _finalize_ttft_reasoning_deltas(pending_reasoning_deltas, now=now), started_at
316
+ )
317
+
318
+
319
+ # Stream frames whose parsed payload feeds a real per-event consumer:
320
+ # lifecycle/terminal handling and usage settlement (created/queued/in_progress/
321
+ # completed/failed/incomplete/error), parallel tool-call rewrite + duplicate
322
+ # side-effect suppression (response.output_item.*), and text-done suppression
323
+ # (response.output_text.done / response.content_part.done). Canonically framed
324
+ # frames of any other type can relay upstream bytes verbatim once the TTFT
325
+ # window is settled and no service-tier snapshot is present.
326
+ _MUST_PARSE_STREAM_EVENT_TYPES = frozenset(
327
+ {
328
+ "response.created",
329
+ "response.queued",
330
+ "response.in_progress",
331
+ "response.completed",
332
+ "response.failed",
333
+ "response.incomplete",
334
+ "error",
335
+ "response.output_item.added",
336
+ "response.output_item.done",
337
+ "response.output_text.done",
338
+ "response.content_part.done",
339
+ }
340
+ )
341
+ # Raw-line gate for service-tier attribution: response snapshots carry
342
+ # `"service_tier"` in their JSON, and a false positive (the substring inside
343
+ # delta text) merely takes the full-parse path.
344
+ _SERVICE_TIER_MARKER = '"service_tier"'
345
+
346
+
347
+ def _verbatim_relay_event_type(
348
+ line: str,
349
+ latency_first_token_ms: int | None,
350
+ pending_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState],
351
+ ) -> str | None:
352
+ """Return the cheap event type when a stream frame can relay verbatim.
353
+
354
+ Eligible frames are canonically framed (leading ``event: <type>`` line,
355
+ single JSON-object ``data:`` line — see ``sse_event_type_from_block``),
356
+ outside the must-parse set, past the TTFT first-token window (including a
357
+ pending reasoning-delta window), and free of the service-tier marker.
358
+ Everything else returns ``None`` and takes the full parse +
359
+ ``format_sse_event`` path, preserving the EventSource framing guarantee
360
+ for data-only blocks.
361
+ """
362
+ if latency_first_token_ms is None or pending_reasoning_deltas:
363
+ return None
364
+ if _SERVICE_TIER_MARKER in line:
365
+ return None
366
+ event_type = sse_event_type_from_block(line)
367
+ if event_type is None or event_type in _MUST_PARSE_STREAM_EVENT_TYPES:
368
+ return None
369
+ return event_type
370
+
371
+
372
+ def _bind_propagated_capacity_startup_wait(event: asyncio.Event) -> Token[asyncio.Event | None]:
373
+ return _PROPAGATED_CAPACITY_STARTUP_WAIT.set(event)
374
+
375
+
376
+ def _reset_propagated_capacity_startup_wait(token: Token[asyncio.Event | None]) -> None:
377
+ _PROPAGATED_CAPACITY_STARTUP_WAIT.reset(token)
378
+
379
+
380
+ def _bind_propagated_capacity_startup_ready(event: asyncio.Event) -> Token[asyncio.Event | None]:
381
+ return _PROPAGATED_CAPACITY_STARTUP_READY.set(event)
382
+
383
+
384
+ def _reset_propagated_capacity_startup_ready(token: Token[asyncio.Event | None]) -> None:
385
+ _PROPAGATED_CAPACITY_STARTUP_READY.reset(token)
386
+
387
+
388
+ def _signal_propagated_capacity_startup_wait() -> None:
389
+ ready_event = _PROPAGATED_CAPACITY_STARTUP_READY.get()
390
+ if ready_event is not None:
391
+ ready_event.clear()
392
+ event = _PROPAGATED_CAPACITY_STARTUP_WAIT.get()
393
+ if event is not None:
394
+ event.set()
395
+
396
+
397
+ def _signal_propagated_capacity_startup_ready() -> None:
398
+ wait_event = _PROPAGATED_CAPACITY_STARTUP_WAIT.get()
399
+ if wait_event is not None:
400
+ wait_event.clear()
401
+ event = _PROPAGATED_CAPACITY_STARTUP_READY.get()
402
+ if event is not None:
403
+ event.set()
404
+
405
+
406
+ def _bind_propagated_responses_service_cleanup_ready(event: asyncio.Event) -> Token[asyncio.Event | None]:
407
+ return _PROPAGATED_RESPONSES_SERVICE_CLEANUP_READY.set(event)
408
+
409
+
410
+ def _reset_propagated_responses_service_cleanup_ready(token: Token[asyncio.Event | None]) -> None:
411
+ _PROPAGATED_RESPONSES_SERVICE_CLEANUP_READY.reset(token)
412
+
413
+
414
+ def _signal_propagated_responses_service_cleanup_ready() -> None:
415
+ event = _PROPAGATED_RESPONSES_SERVICE_CLEANUP_READY.get()
416
+ if event is not None:
417
+ event.set()
418
+
419
+
420
+ def _bind_propagated_responses_owner_forward_dispatched(event: asyncio.Event) -> Token[asyncio.Event | None]:
421
+ return _PROPAGATED_RESPONSES_OWNER_FORWARD_DISPATCHED.set(event)
422
+
423
+
424
+ def _reset_propagated_responses_owner_forward_dispatched(token: Token[asyncio.Event | None]) -> None:
425
+ _PROPAGATED_RESPONSES_OWNER_FORWARD_DISPATCHED.reset(token)
426
+
427
+
428
+ def _signal_propagated_responses_owner_forward_dispatched() -> None:
429
+ event = _PROPAGATED_RESPONSES_OWNER_FORWARD_DISPATCHED.get()
430
+ if event is not None:
431
+ event.set()
432
+
433
+
434
+ def _bind_propagated_responses_owner_forward_rejected(event: asyncio.Event) -> Token[asyncio.Event | None]:
435
+ return _PROPAGATED_RESPONSES_OWNER_FORWARD_REJECTED.set(event)
436
+
437
+
438
+ def _reset_propagated_responses_owner_forward_rejected(token: Token[asyncio.Event | None]) -> None:
439
+ _PROPAGATED_RESPONSES_OWNER_FORWARD_REJECTED.reset(token)
440
+
441
+
442
+ def _signal_propagated_responses_owner_forward_rejected() -> None:
443
+ event = _PROPAGATED_RESPONSES_OWNER_FORWARD_REJECTED.get()
444
+ if event is not None:
445
+ event.set()
446
+
447
+
448
+ def _account_selection_recovery_sleep_seconds_from_message(
449
+ message: str | None,
450
+ *,
451
+ error_code: str | None = None,
452
+ suppress_uncoded_local_selector_hint: bool = False,
453
+ ) -> float | None:
454
+ message = (message or "").strip()
455
+ if not message:
456
+ return None
457
+
458
+ lowered = message.lower()
459
+ if (
460
+ "require re-authentication" in lowered
461
+ or "all accounts are paused" in lowered
462
+ or "no accounts with a plan" in lowered
463
+ or "no accounts with available additional quota" in lowered
464
+ or "no fresh additional quota data" in lowered
465
+ or (
466
+ (error_code == "no_accounts" or (suppress_uncoded_local_selector_hint and error_code is None))
467
+ and lowered.startswith("rate limit exceeded. try again in")
468
+ )
469
+ ):
470
+ return None
471
+
472
+ retry_hint = _ACCOUNT_SELECTION_RETRY_HINT_RE.search(message)
473
+ if retry_hint is not None:
474
+ try:
475
+ hinted_seconds = float(retry_hint.group(1))
476
+ except ValueError:
477
+ hinted_seconds = _ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS
478
+ return min(
479
+ max(hinted_seconds, _ACCOUNT_SELECTION_RECOVERY_MIN_SLEEP_SECONDS),
480
+ _ACCOUNT_SELECTION_RECOVERY_MAX_SLEEP_SECONDS,
481
+ )
482
+
483
+ if "hit your spend cap set by the owner of your workspace" in lowered:
484
+ return _ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS
485
+
486
+ # A hard affinity row is an ownership constraint, so it must not spill to
487
+ # another account. Capacity/health transitions can nevertheless make the
488
+ # owner briefly unavailable; give that owner a short recovery window before
489
+ # surfacing a 503 rather than rebinding the logical session.
490
+ if error_code == "hard_affinity_saturated":
491
+ return _HARD_AFFINITY_RECOVERY_SLEEP_SECONDS
492
+
493
+ if error_code in _LOCAL_ACCOUNT_CAP_ERROR_CODES:
494
+ return _ACCOUNT_SELECTION_RECOVERY_DEFAULT_SLEEP_SECONDS
495
+
496
+ return None
497
+
498
+
499
+ def _account_selection_recovery_sleep_seconds(selection: AccountSelection) -> float | None:
500
+ """The wait this selection failure earns before the caller re-selects.
501
+
502
+ ``hard_affinity_owner_excluded`` is the selector's proof that the hard
503
+ ``CODEX_SESSION`` owner it resolved is one of the caller's own
504
+ ``exclude_account_ids``. The owner cannot become selectable while that
505
+ exclusion holds and a hard row never spills to another account, so the
506
+ short owner-recovery window earns nothing here: every re-selection would
507
+ report the same ``hard_affinity_saturated`` until the request budget is
508
+ spent (7200s on the HTTP bridge), long after the client gave up. Fail
509
+ closed at once instead -- the same fail-closed the bridge already applies
510
+ when the request excludes the account its hard session is bound to
511
+ (``_require_http_bridge_bound_account_not_excluded``) -- and let the
512
+ client's own retry reach the owner with no exclusion. A genuinely
513
+ unavailable owner the caller did NOT exclude keeps its recovery wait.
514
+ """
515
+ if selection.hard_affinity_owner_excluded:
516
+ return None
517
+ return _account_selection_recovery_sleep_seconds_from_message(
518
+ selection.error_message,
519
+ error_code=selection.error_code,
520
+ suppress_uncoded_local_selector_hint=selection.account is None,
521
+ )
522
+
523
+
524
+ def _account_capacity_wait_payload(
525
+ request_state: "_WebSocketRequestState | None",
526
+ *,
527
+ request_id: str | None,
528
+ reason: str | None,
529
+ retry_after_seconds: float | None,
530
+ started_at: float | None = None,
531
+ now: float,
532
+ ) -> dict[str, JsonValue]:
533
+ wait_started_at = request_state.account_capacity_wait_started_at if request_state is not None else started_at
534
+ # ``now`` must come from the clock that stamped ``wait_started_at``;
535
+ # callers pass their owner clock sample.
536
+ waited_seconds = int(max(0.0, now - wait_started_at)) if wait_started_at is not None else 0
537
+ payload: dict[str, JsonValue] = {
538
+ "type": "codex.keepalive",
539
+ "status": "waiting_for_account_capacity",
540
+ "request_id": request_id or (request_state.request_id if request_state is not None else None),
541
+ "waited_seconds": waited_seconds,
542
+ }
543
+ if reason:
544
+ payload["reason"] = reason
545
+ if retry_after_seconds is not None:
546
+ payload["retry_after_seconds"] = int(max(0.0, retry_after_seconds))
547
+ return payload
548
+
549
+
550
+ async def _sleep_for_account_selection_recovery(
551
+ selection: AccountSelection,
552
+ *,
553
+ request_id: str | None,
554
+ kind: str,
555
+ request_stage: str,
556
+ model: str | None,
557
+ max_sleep_seconds: float | None = None,
558
+ request_state: "_WebSocketRequestState | None" = None,
559
+ heartbeat: Callable[[float], Awaitable[None]] | None = None,
560
+ scheduler: Scheduler,
561
+ clock: Clock,
562
+ ) -> bool:
563
+ sleep_seconds = _account_selection_recovery_sleep_seconds(selection)
564
+ if sleep_seconds is None:
565
+ return False
566
+ if max_sleep_seconds is not None:
567
+ if max_sleep_seconds <= 0:
568
+ return False
569
+ sleep_seconds = min(sleep_seconds, max_sleep_seconds)
570
+
571
+ if request_state is not None:
572
+ request_state.account_capacity_waiting = True
573
+ request_state.account_capacity_wait_reason = selection.error_message
574
+ request_state.account_capacity_wait_started_at = (
575
+ request_state.account_capacity_wait_started_at or clock.monotonic()
576
+ )
577
+ request_state.account_capacity_wait_retry_after_seconds = sleep_seconds
578
+
579
+ logger.info(
580
+ "Waiting for an account to recover before retrying selection request_id=%s kind=%s "
581
+ "request_stage=%s model=%s sleep_seconds=%.1f error=%s",
582
+ request_id,
583
+ kind,
584
+ request_stage,
585
+ model,
586
+ sleep_seconds,
587
+ selection.error_message,
588
+ )
589
+ remaining = sleep_seconds
590
+ try:
591
+ while remaining > 0:
592
+ if heartbeat is not None:
593
+ await heartbeat(remaining)
594
+ chunk = min(remaining, _ACCOUNT_SELECTION_RECOVERY_HEARTBEAT_SECONDS)
595
+ await scheduler.sleep(chunk)
596
+ remaining -= chunk
597
+ finally:
598
+ if request_state is not None:
599
+ request_state.account_capacity_waiting = False
600
+ request_state.account_capacity_wait_reason = None
601
+ request_state.account_capacity_wait_retry_after_seconds = None
602
+ return True
603
+
604
+
605
+ async def _call_with_supported_optional_kwargs(
606
+ func: Callable[..., Awaitable[Any]],
607
+ /,
608
+ *args: Any,
609
+ optional_kwargs: Mapping[str, Any],
610
+ **required_kwargs: Any,
611
+ ) -> Any:
612
+ return await func(*args, **_supported_optional_kwargs(func, optional_kwargs, required_kwargs))
613
+
614
+
615
+ def _supported_optional_kwargs(
616
+ func: Callable[..., Any],
617
+ optional_kwargs: Mapping[str, Any],
618
+ required_kwargs: Mapping[str, Any],
619
+ ) -> dict[str, Any]:
620
+ kwargs = dict(required_kwargs)
621
+ kwargs.update(optional_kwargs)
622
+ if optional_kwargs:
623
+ try:
624
+ signature = inspect.signature(func)
625
+ except (TypeError, ValueError):
626
+ signature = None
627
+ accepts_var_keyword = signature is not None and any(
628
+ parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()
629
+ )
630
+ if signature is not None and not accepts_var_keyword:
631
+ for name in optional_kwargs:
632
+ if name not in signature.parameters:
633
+ kwargs.pop(name, None)
634
+ return kwargs
635
+
636
+
637
+ _CONVERSATION_HEADERS_BY_USERAGENT_PREFIX = (
638
+ ("opencode", ("x-parent-session-id", "x-opencode-session", "x-session-id", "x-session-affinity")),
639
+ ("codex", ("thread-id",)),
640
+ )
641
+
642
+
643
+ def _request_log_useragent_fields(headers: Mapping[str, str]) -> tuple[str | None, str | None]:
644
+ raw_useragent = next((value for key, value in headers.items() if key.lower() == "user-agent"), None)
645
+ if raw_useragent is None:
646
+ return None, None
647
+ useragent = raw_useragent.strip()
648
+ if not useragent:
649
+ return None, None
650
+ first_token = useragent.split(maxsplit=1)[0]
651
+ useragent_group = first_token.split("/", 1)[0].strip() or None
652
+ if "/" in useragent:
653
+ useragent_group = useragent.split("/", 1)[0]
654
+ return useragent, useragent_group
655
+
656
+
657
+ def _request_log_client_fields(
658
+ headers: Mapping[str, str],
659
+ ) -> tuple[str | None, str | None, str | None]:
660
+ useragent, useragent_group = _request_log_useragent_fields(headers)
661
+ normalized_useragent = (useragent or "").strip().casefold()
662
+ normalized_headers = {key.casefold(): value for key, value in headers.items()}
663
+ for prefix, header_names in _CONVERSATION_HEADERS_BY_USERAGENT_PREFIX:
664
+ if normalized_useragent.startswith(prefix):
665
+ for header_name in header_names:
666
+ value = normalized_headers.get(header_name)
667
+ if value and (conversation_id := value.strip()):
668
+ return useragent, useragent_group, conversation_id
669
+ break
670
+ return useragent, useragent_group, None
671
+
672
+
673
+ class _RetryableStreamError(Exception):
674
+ def __init__(self, code: str, error: UpstreamError, *, exclude_account: bool = False) -> None:
675
+ super().__init__(code)
676
+ self.code = code
677
+ self.error = error
678
+ self.exclude_account = exclude_account
679
+
680
+
681
+ class _WebSocketConnectFailureEmitted(Exception):
682
+ pass
683
+
684
+
685
+ class _WebSocketTransientRefreshFailover(Exception):
686
+ """Signals the WebSocket connect loop to fail over after a transient refresh.
687
+
688
+ Raised from the connect attempt when a non-permanent, transport-level
689
+ ``RefreshError`` (for example ``refresh_claim_timeout`` when another replica
690
+ holds the account's refresh claim) reaches the connect path and the request
691
+ is not bound to a preferred/required account. The loop releases the skipped
692
+ account's stream lease, excludes it, and reselects a healthy account instead
693
+ of surfacing a bogus 401 ``invalid_api_key``.
694
+ """
695
+
696
+ def __init__(self, account_id: str) -> None:
697
+ super().__init__(account_id)
698
+ self.account_id = account_id
699
+
700
+
701
+ # ---- Refresh-claim contention failover helpers -----------------------------
702
+ #
703
+ # A transient cross-replica refresh contention (``is_transient_refresh_contention``:
704
+ # ONLY the benign ``refresh_claim_timeout`` claim-contention code and the
705
+ # post-exchange ``token_persist_conflict`` / ``status_downgrade_conflict`` CAS
706
+ # codes, NOT the broad ``transport_error`` flag) means the account's credentials
707
+ # are healthy and only its refresh claim/persist lost to a peer replica. These
708
+ # helpers let the proxy previsible-unary failover surface a retryable
709
+ # ``upstream_unavailable`` WITHOUT recording an account-health penalty, while a
710
+ # genuine OAuth transport failure keeps its normal health accounting.
711
+
712
+ _FAILED_ACCOUNT_ATTR = "_codex_lb_failed_account"
713
+ _CLAIM_CONTENTION_UNPENALIZED_ATTR = "_codex_lb_claim_contention_unpenalized"
714
+
715
+
716
+ class _RefreshFailoverProxy(Protocol):
717
+ async def _handle_stream_error(
718
+ self,
719
+ account: Account,
720
+ error: Any,
721
+ code: str,
722
+ http_status: int | None = None,
723
+ *,
724
+ privacy_policy: CodexControlRequestPrivacyPolicy = CodexControlRequestPrivacyPolicy.STANDARD,
725
+ retry_after_seconds: float | None = None,
726
+ burst_cooldown_recorded: bool = False,
727
+ upstream_http_status: int | None = None,
728
+ ) -> Any: ...
729
+
730
+
731
+ def is_claim_contention_unpenalized(exc: object) -> bool:
732
+ """True when ``exc`` is a retryable ``upstream_unavailable`` from claim contention.
733
+
734
+ The account is healthy (a peer replica held its refresh claim), so callers
735
+ MUST NOT record an account-health penalty for it.
736
+ """
737
+ return bool(getattr(exc, _CLAIM_CONTENTION_UNPENALIZED_ATTR, False))
738
+
739
+
740
+ def raise_proxy_unavailable_for_claim_contention(message: str, account: Account) -> NoReturn:
741
+ """Raise a retryable ``upstream_unavailable`` marked as (unpenalized) claim contention."""
742
+ exc = ProxyResponseError(502, openai_error("upstream_unavailable", message))
743
+ setattr(exc, _FAILED_ACCOUNT_ATTR, account)
744
+ setattr(exc, _CLAIM_CONTENTION_UNPENALIZED_ATTR, True)
745
+ raise exc
746
+
747
+
748
+ def _raise_proxy_unavailable_for_account(message: str, account: Account) -> NoReturn:
749
+ exc = ProxyResponseError(502, openai_error("upstream_unavailable", message))
750
+ setattr(exc, _FAILED_ACCOUNT_ATTR, account)
751
+ raise exc
752
+
753
+
754
+ async def failover_after_previsible_refresh_error(
755
+ proxy: _RefreshFailoverProxy,
756
+ exc: Exception,
757
+ current: Account,
758
+ *,
759
+ attempt: int,
760
+ max_account_attempts: int,
761
+ strict_account_id: str | None,
762
+ excluded_account_ids: set[str],
763
+ select_next_account: Callable[[set[str]], Awaitable[AccountSelection]],
764
+ request_id: str,
765
+ kind: str,
766
+ privacy_policy: CodexControlRequestPrivacyPolicy = CodexControlRequestPrivacyPolicy.STANDARD,
767
+ ) -> Account:
768
+ """Pick the next account after a previsible-unary freshness/connect failure.
769
+
770
+ Consolidates the transient ``RefreshError`` claim-contention and transport
771
+ paths plus raw aiohttp/connect errors into one decision:
772
+
773
+ * Transient refresh contention (``is_transient_refresh_contention``) is ALWAYS
774
+ retryable and NEVER penalizes the skipped account (its credentials are
775
+ healthy; only its refresh claim/persist lost to a peer). This covers BOTH
776
+ benign claim contention and a post-exchange persist/status CAS conflict; the
777
+ latter is logged distinctly (a rarer, more-serious internal race). On
778
+ strict-pin / attempt exhaustion or an empty selection it raises a
779
+ claim-contention-marked ``upstream_unavailable`` that the caller's terminal
780
+ ``_handle_proxy_error`` skips penalizing.
781
+ * A genuine OAuth ``transport_error`` refresh failure or a raw aiohttp/connect
782
+ failure keeps its health accounting: failover is gated on the message text
783
+ and the skipped account is penalized via ``_handle_stream_error`` so a
784
+ persistently broken account backs off.
785
+
786
+ Returns the next account to try; raises otherwise.
787
+ """
788
+ from app.modules.proxy._service.streaming.helpers import _should_retry_transient_stream_error
789
+
790
+ claim = isinstance(exc, RefreshError) and is_transient_refresh_contention(exc)
791
+ contention_kind = refresh_contention_kind(exc) if isinstance(exc, RefreshError) else None
792
+ message = getattr(exc, "message", None) or str(exc) or "Request to upstream timed out"
793
+ if contention_kind == "persist_conflict":
794
+ # Post-exchange guarded-write CAS conflict: rarer/more-serious than benign
795
+ # claim contention, so surface it distinctly (it still fails over with no
796
+ # penalty, identically to benign contention).
797
+ logger.warning(
798
+ "%s refresh post-exchange persist conflict code=%s request_id=%s account_id=%s",
799
+ kind,
800
+ getattr(exc, "code", None),
801
+ request_id,
802
+ "<redacted>" if privacy_policy.redacts_sensitive_details else current.id,
803
+ exc_info=None if privacy_policy.redacts_sensitive_details else True,
804
+ )
805
+ else:
806
+ logger.warning(
807
+ "%s refresh %s request_id=%s account_id=%s",
808
+ kind,
809
+ "claim contention" if claim else "failed",
810
+ request_id,
811
+ "<redacted>" if privacy_policy.redacts_sensitive_details else current.id,
812
+ exc_info=None if privacy_policy.redacts_sensitive_details else True,
813
+ )
814
+ if not claim and not _should_retry_transient_stream_error("upstream_unavailable", message):
815
+ _raise_proxy_unavailable_for_account(message, current)
816
+
817
+ def _give_up() -> NoReturn:
818
+ if claim:
819
+ raise_proxy_unavailable_for_claim_contention(message, current)
820
+ _raise_proxy_unavailable_for_account(message, current)
821
+
822
+ if (strict_account_id is not None and current.id == strict_account_id) or attempt >= max_account_attempts:
823
+ _give_up()
824
+ excluded_account_ids.add(current.id)
825
+ selection = await select_next_account(excluded_account_ids)
826
+ selected_account = selection.account
827
+ if selected_account is None:
828
+ _give_up()
829
+ assert selected_account is not None
830
+ if not claim:
831
+ # Genuine transport / connect failure: penalize the skipped account so a
832
+ # persistently broken account backs off. Claim contention never penalizes.
833
+ if privacy_policy.redacts_sensitive_details:
834
+ await proxy._handle_stream_error(
835
+ current,
836
+ {"message": message},
837
+ "upstream_unavailable",
838
+ privacy_policy=privacy_policy,
839
+ )
840
+ else:
841
+ await proxy._handle_stream_error(current, {"message": message}, "upstream_unavailable")
842
+ return selected_account
843
+
844
+
845
+ class _TransientStreamError(Exception):
846
+ """Transient upstream error (e.g. 500 server_error) - retry on same account first."""
847
+
848
+ def __init__(
849
+ self,
850
+ code: str,
851
+ error: UpstreamError,
852
+ *,
853
+ preserve_on_selection_exhausted: bool = False,
854
+ account_health_error: bool = False,
855
+ ) -> None:
856
+ super().__init__(code)
857
+ self.code = code
858
+ self.error = error
859
+ self.preserve_on_selection_exhausted = preserve_on_selection_exhausted
860
+ self.account_health_error = account_health_error
861
+
862
+
863
+ class _TerminalStreamError(Exception):
864
+ def __init__(self, code: str, error: UpstreamError) -> None:
865
+ super().__init__(code)
866
+ self.code = code
867
+ self.error = error
868
+
869
+
870
+ @dataclass
871
+ class _ApiKeyReservationTouchState:
872
+ last_touch_at: float
873
+
874
+
875
+ @dataclass
876
+ class _StreamSettlement:
877
+ """Populated by _stream_once(), consumed by _stream_with_retry() for reservation settlement."""
878
+
879
+ status: str = "success"
880
+ model: str = ""
881
+ service_tier: str | None = None
882
+ input_tokens: int | None = None
883
+ output_tokens: int | None = None
884
+ cached_input_tokens: int | None = None
885
+ error_code: str | None = None
886
+ error_message: str | None = None
887
+ error: UpstreamError | None = None
888
+ account_health_error: bool = False
889
+ settlement_order_required: bool = False
890
+ record_success: bool = True
891
+ downstream_visible: bool = False
892
+ downstream_text_visible: bool = False
893
+ response_id: str | None = None
894
+ usage_settlement_transferred: bool = False
895
+ # Monotonic instant the upstream terminal frame (``response.completed`` /
896
+ # ``response.failed`` / ``response.incomplete`` / ``error``) was parsed by
897
+ # ``_stream_once``, before it is yielded downstream. The throughput cohort
898
+ # sample ends its generation span here; the row's ``latency_ms`` keeps
899
+ # measuring to the generator's close (downstream flush, upstream EOF).
900
+ upstream_terminal_at: float | None = None
901
+
902
+ def reset(self) -> None:
903
+ fresh = type(self)()
904
+ self.__dict__.update(fresh.__dict__)
905
+
906
+
907
+ def _stream_settlement_error_payload(settlement: _StreamSettlement) -> UpstreamError:
908
+ if settlement.error is not None:
909
+ return settlement.error
910
+ payload: UpstreamError = {}
911
+ if settlement.error_message:
912
+ payload["message"] = settlement.error_message
913
+ else:
914
+ payload["message"] = "Upstream error"
915
+ return payload
916
+
917
+
918
+ def _consume_api_key_reservation_heartbeat_result(task: asyncio.Task[None]) -> None:
919
+ try:
920
+ task.result()
921
+ except asyncio.CancelledError:
922
+ pass
923
+ except Exception:
924
+ logger.warning("API key reservation heartbeat task failed during cancellation", exc_info=True)
925
+
926
+
927
+ @dataclass(frozen=True, slots=True)
928
+ class _RequestLogFailureMetadata:
929
+ failure_phase: str | None = None
930
+ failure_detail: str | None = None
931
+ failure_exception_type: str | None = None
932
+ upstream_status_code: int | None = None
933
+ upstream_error_code: str | None = None
934
+ bridge_stage: str | None = None
935
+
936
+
937
+ @dataclass(slots=True)
938
+ class _HTTPBridgeCompletedDeliveryScope:
939
+ active: bool = False
940
+ terminal_enqueued: bool = False
941
+
942
+
943
+ @dataclass(slots=True)
944
+ class _DeferredAccountBackoffLifecycle:
945
+ reservation: ApiKeyUsageReservationData | None
946
+ pending_backoffs: dict[str, Account] = field(default_factory=dict)
947
+ settlement_owned: bool = False
948
+ settlement_confirmed: bool = False
949
+
950
+
951
+ @dataclass(slots=True)
952
+ class _DeferredAccountBackoffTracker:
953
+ current_lifecycle: _DeferredAccountBackoffLifecycle | None = None
954
+
955
+
956
+ @dataclass(slots=True)
957
+ class _DeferredKeyedStreamHealthPenalty:
958
+ """Classified account-health write deferred until reservation settlement."""
959
+
960
+ account: Account
961
+ error: UpstreamError
962
+ code: str
963
+
964
+
965
+ @dataclass(eq=False, slots=True)
966
+ class _HTTPBridgeResponseCreateAttempt:
967
+ ordinal: int
968
+ disarmed: bool = False
969
+ response_observed: bool = False
970
+ # A non-terminal response event (a deferred-reasoning prelude, for
971
+ # example) proves the attempt was answered midstream even when ordinary
972
+ # event accounting was deliberately skipped. A later terminal failure
973
+ # frame must then not be charged as a pre-response strike.
974
+ non_terminal_response_observed: bool = False
975
+ retry_circuit_failure_recorded: bool = False
976
+ retry_circuit_failure_settled: anyio.Event | None = None
977
+
978
+
979
+ @dataclass(frozen=True, slots=True)
980
+ class _HTTPBridgeRetryCircuitAttemptSelection:
981
+ kind: Literal["absent", "eligible", "recorded", "settled", "ineligible"]
982
+ attempts: tuple[_HTTPBridgeResponseCreateAttempt, ...] = ()
983
+
984
+ def __post_init__(self) -> None:
985
+ carries_attempts = self.kind in {"eligible", "recorded", "settled"}
986
+ if carries_attempts != bool(self.attempts):
987
+ raise ValueError(f"invalid retry-circuit attempt selection: {self.kind}")
988
+
989
+ @property
990
+ def attempt(self) -> _HTTPBridgeResponseCreateAttempt | None:
991
+ return self.attempts[0] if len(self.attempts) == 1 else None
992
+
993
+ @property
994
+ def ambiguous(self) -> bool:
995
+ return len(self.attempts) > 1
996
+
997
+
998
+ @dataclass
999
+ class _WebSocketRequestState:
1000
+ request_id: str
1001
+ model: str | None
1002
+ service_tier: str | None
1003
+ reasoning_effort: str | None
1004
+ api_key_reservation: ApiKeyUsageReservationData | None
1005
+ started_at: float
1006
+ responses_lite_model: str | None = None
1007
+ latency_first_token_ms: int | None = None
1008
+ ttft_reasoning_deltas: dict[tuple[str | None, int | None, int | None], _TTFTReasoningDeltaState] = field(
1009
+ default_factory=dict
1010
+ )
1011
+ latency_response_created_ms: int | None = None
1012
+ latency_first_upstream_event_ms: int | None = None
1013
+ latency_response_create_gate_wait_ms: int | None = None
1014
+ latency_bridge_queue_wait_ms: int | None = None
1015
+ # Monotonic instant the upstream terminal event (``response.completed`` /
1016
+ # ``response.failed`` / ``response.incomplete`` / ``error``) was parsed for
1017
+ # this turn, before terminal bookkeeping, API-key settlement and cleanup.
1018
+ # The throughput cohort sample ends its generation span here; the row's
1019
+ # ``latency_ms`` keeps measuring to the end of the finalizer.
1020
+ upstream_terminal_at: float | None = None
1021
+ response_create_gate_wait_started_at: float | None = None
1022
+ # Monotonic time immediately before the current upstream response.create
1023
+ # send. Retries replace this value so admission wait and prior attempts do
1024
+ # not age a fresh send into the eventless owner deadline.
1025
+ response_create_sent_at: float | None = None
1026
+ response_create_attempt_count: int = 0
1027
+ response_create_attempt: _HTTPBridgeResponseCreateAttempt | None = None
1028
+ bridge_queue_wait_started_at: float | None = None
1029
+ # Monotonic deadline of the original bridge request budget. Retry and
1030
+ # recovery paths re-prepare request states with a fresh started_at, so
1031
+ # budget clamps must use this instead of recomputing from started_at.
1032
+ bridge_request_deadline: float | None = None
1033
+ prewarm_status: str | None = None
1034
+ prewarm_latency_ms: int | None = None
1035
+ session_previous_gap_ms: int | None = None
1036
+ request_log_id: str | None = None
1037
+ archive_request_id: str | None = None
1038
+ requested_service_tier: str | None = None
1039
+ actual_service_tier: str | None = None
1040
+ response_id: str | None = None
1041
+ awaiting_response_created: bool = False
1042
+ event_queue: asyncio.Queue[str | None] | None = None
1043
+ transport: str = _REQUEST_TRANSPORT_WEBSOCKET
1044
+ upstream_transport: str | None = _REQUEST_TRANSPORT_WEBSOCKET
1045
+ enforce_openai_sdk_contract: bool = True
1046
+ propagate_http_errors: bool = False
1047
+ request_kind: str = "normal"
1048
+ connection_request_kind: str | None = None
1049
+ generate_false_prewarm: bool = False
1050
+ api_key: ApiKeyData | None = None
1051
+ # The client's requested model captured before api-key enforcement
1052
+ # normalized aliases (``gpt-5-high`` -> ``gpt-5``), with the key's
1053
+ # ``enforced_model`` substituted and the fast-mode correction applied,
1054
+ # exactly like the HTTP path's ``raw_source_model``. Consumed only by the
1055
+ # WebSocket source-ownership guards; it must never reach the upstream
1056
+ # wire payload. ``None`` on request states that were not built by
1057
+ # ``_prepare_websocket_response_create_request`` (replays, archives),
1058
+ # which keeps those on the normalized-model check.
1059
+ raw_source_model: str | None = None
1060
+ # True when the Codex HTTP route structurally excludes this request from
1061
+ # model-source routing: a terminal compaction trigger or ``input_file``
1062
+ # references pinned to the uploading subscription account. Previous
1063
+ # response ownership is recorded separately after continuity lookup.
1064
+ source_route_excluded: bool = False
1065
+ request_usage_budget: ApiKeyRequestUsageBudget | None = None
1066
+ request_text: str | None = None
1067
+ replay_count: int = 0
1068
+ # Counts only the one extra replay permitted after the initial recovery
1069
+ # replay when the replacement upstream socket also closes cleanly before
1070
+ # producing any response event.
1071
+ clean_close_replay_count: int = 0
1072
+ clean_close_retry_in_progress: bool = False
1073
+ clean_close_retry_result: bool | None = None
1074
+ clean_close_retry_close_generation: int | None = None
1075
+ auth_replay_count: int = 0
1076
+ auth_replay_counts_by_account: dict[str, int] = field(default_factory=dict)
1077
+ force_refresh_account_id: str | None = None
1078
+ excluded_account_ids: set[str] = field(default_factory=set)
1079
+ # A narrowly classified pre-acceptance account/model rejection may move
1080
+ # once to another account. Keep the original upstream error until that
1081
+ # replacement connection is established so an empty replacement pool does
1082
+ # not turn the upstream 400 into a proxy-generated 5xx/no-accounts error.
1083
+ precreated_replay_reason: str | None = None
1084
+ precreated_replay_account_id: str | None = None
1085
+ skip_request_log: bool = False
1086
+ previous_response_id: str | None = None
1087
+ session_id: str | None = None
1088
+ # Session headers provide locality, but only a previous response or
1089
+ # explicit turn-state header guarantees continuity for stale recovery.
1090
+ hard_continuity_anchor: bool = False
1091
+ proxy_injected_previous_response_id: bool = False
1092
+ # The durable lookup carried an anchor, but its owner was already stale,
1093
+ # ownerless, or lease-expired when this request arrived. Such a request
1094
+ # must not be presented to the client as a retryable upstream timeout.
1095
+ durable_owner_dead: bool = False
1096
+ # True only when the client's own incoming payload (before this anchor was
1097
+ # injected or trimmed) already looked like a full conversation resend
1098
+ # (``_http_bridge_payload_looks_like_full_resend``). Deliberately weaker
1099
+ # than ``fresh_upstream_request_is_retry_safe``: it does not require the
1100
+ # exact-manifest/retained-output proof that field needs for an in-place
1101
+ # same-request replay, only that the client is a resend-capable client at
1102
+ # all. Used to decide whether it is safe to invalidate a durable session's
1103
+ # stored anchor after this request proves eventless -- a client that
1104
+ # already resends full history on its own can recover unanchored; a
1105
+ # genuine delta-only client has no other way to convey prior context and
1106
+ # must stay anchored.
1107
+ proxy_injected_anchor_had_full_resend_payload: bool = False
1108
+ # Process-local denial generation captured before any canonical session
1109
+ # exists. A detached predecessor can reject the anchor while the request
1110
+ # is waiting for owner resolution; retain that generation through retries
1111
+ # so the successor's final dispatch fence still fails closed.
1112
+ denied_proxy_injected_anchor_fence_generation_at_prepare: int | None = None
1113
+ # Generation equality alone cannot distinguish a request captured after a
1114
+ # denial from one captured before it. Preserve the tombstone observation on
1115
+ # the prepared request so a stale durable lookup cannot be redispatched.
1116
+ denied_proxy_injected_anchor_fence_response_id: str | None = None
1117
+ denied_proxy_injected_anchor_fence_request_id: str | None = None
1118
+ denied_proxy_injected_anchor_fence_was_already_denied: bool = False
1119
+ expose_stale_previous_response_classifier: bool = False
1120
+ fresh_upstream_request_text: str | None = None
1121
+ # True only when ``fresh_upstream_request_text`` contains a *safe* pre-
1122
+ # injection form of this request that can be replayed as a fresh turn.
1123
+ # Durable-anchor injection captures the original unanchored full-resend
1124
+ # payload, so dropping the anchor and replaying is equivalent to the
1125
+ # client's own retry. Session-level anchor injection does not set this:
1126
+ # the original payload may have omitted history the conversation depended
1127
+ # on, and dropping the anchor there would silently turn a continuation into
1128
+ # a context-free fresh turn.
1129
+ fresh_upstream_request_is_retry_safe: bool = False
1130
+ # Memo for the account installation-id stamp applied to ``request_text`` /
1131
+ # ``fresh_upstream_request_text`` on the HTTP bridge submit path. The stamp
1132
+ # is re-applied at several submit and retry sites; these fields remember the
1133
+ # exact ``str`` objects the last stamp returned for ``codex_installation_id``
1134
+ # so an already-stamped object is recognised by identity instead of being
1135
+ # decoded and re-encoded again. Every text rewrite yields a new ``str``
1136
+ # object and an account swap changes the id, so both miss automatically.
1137
+ installation_stamp_installation_id: str | None = None
1138
+ installation_stamp_text: str | None = None
1139
+ installation_stamp_fresh_text: str | None = None
1140
+ # Set only on the internally constructed one-shot request that replaces an
1141
+ # explicitly rejected stale anchor with a verified full-history payload.
1142
+ # It may bypass an older hard-key retry circuit without deleting that
1143
+ # circuit or weakening admission for ordinary client retries.
1144
+ verified_stale_anchor_replay: bool = False
1145
+ # Snapshot of the hard-key circuit observed when the stale-anchor replay
1146
+ # was authorized. ``captured=True`` with ``generation=None`` proves that no
1147
+ # circuit existed; a newer local/durable failure must suppress submit.
1148
+ verified_stale_anchor_retry_circuit_generation_captured: bool = False
1149
+ verified_stale_anchor_retry_circuit_key: _HTTPBridgeSessionKey | None = None
1150
+ verified_stale_anchor_retry_circuit_generation: tuple[int, float, int, float, int, float, float] | None = None
1151
+ verified_stale_anchor_quarantine_generation: int | None = None
1152
+ # The exact half-open lease this request's admission claimed (0.0 when
1153
+ # it claimed none); released by the submit finalizer whenever the probe
1154
+ # was never dispatched, so no pre-dispatch exit can strand the lease.
1155
+ claimed_half_open_until: float = 0.0
1156
+ # True while the submit owns an admission-waiter registration taken at
1157
+ # submit entry, before the retry-circuit gate, that the dispatch path has
1158
+ # not yet taken over. It keeps the turn visible to a concurrent
1159
+ # cooldown-suppressed sibling deciding whether the shared session is
1160
+ # idle; every pre-dispatch exit releases it.
1161
+ admission_waiter_preregistered: bool = False
1162
+ # Stable fingerprint used by the durable recovery-attempt journal. It is
1163
+ # populated only for a proof-gated fresh replay candidate.
1164
+ recovery_attempt_fingerprint: str | None = None
1165
+ recovery_attempt_session_id: str | None = None
1166
+ recovery_attempt_owner_epoch: int | None = None
1167
+ # True when recovery already atomically claimed the journal row as
1168
+ # REPLAYED. Claimed replays must not be re-journaled at dispatch.
1169
+ recovery_attempt_claimed: bool = False
1170
+ # Set once the upstream send is attempted, including an ambiguous send
1171
+ # failure. Pre-dispatch admission/setup failures may safely roll back a
1172
+ # claimed recovery journal; an attempted send must remain consumed.
1173
+ recovery_attempt_dispatched: bool = False
1174
+ recovery_attempt_event_observed: bool = False
1175
+ # Durable operation identity for a continuity-bound response.create. It is
1176
+ # stable across client reconnects with the same parent response and body.
1177
+ operation_id: str | None = None
1178
+ operation_fingerprint: str | None = None
1179
+ operation_parent_response_id: str | None = None
1180
+ operation_registered: bool = False
1181
+ # Account-neutral durable recovery keeps the original operation identity
1182
+ # while asking request submission to rebind it to the replacement session.
1183
+ operation_rebind_required: bool = False
1184
+ # True after an existing UNKNOWN operation is claimed for this attempt.
1185
+ # If admission fails before send, cleanup must restore UNKNOWN rather than
1186
+ # treating the pre-existing row like a newly-created operation.
1187
+ # True only when this request created the durable operation row. A
1188
+ # pre-dispatch admission failure may remove that row; an existing row
1189
+ # represents an ambiguous upstream attempt and must remain fenced.
1190
+ operation_created: bool = False
1191
+ operation_rebound: bool = False
1192
+ operation_rebound_from_session_id: str | None = None
1193
+ operation_rebound_from_account_id: str | None = None
1194
+ operation_rebound_from_model: str | None = None
1195
+ operation_rebound_from_parent_response_id: str | None = None
1196
+ operation_replay: bool = False
1197
+ operation_dispatched: bool = False
1198
+ # Last response identity successfully written to the durable operation.
1199
+ # Retry setup may clear the active response before a replacement is
1200
+ # acknowledged, but fallback settlement must still fence against this ID.
1201
+ operation_persisted_response_id: str | None = None
1202
+ # Responses-Lite model advertised by ``fresh_upstream_request_text``. A
1203
+ # fresh replay built from a trusted marker-only frame has the reserved
1204
+ # marker stripped, so swapping to the fresh body must also swap this onto
1205
+ # ``responses_lite_model``; otherwise the replay's ``response.created``
1206
+ # would be recorded as a Lite acceptance for a non-Lite upstream request.
1207
+ fresh_upstream_request_responses_lite_model: str | None = None
1208
+ request_stage: str = "first_turn"
1209
+ preferred_account_id: str | None = None
1210
+ # Once an account-bound body has been dispatched, retries remain pinned to
1211
+ # that owner even when stale-anchor recovery removes previous_response_id.
1212
+ replay_required_account_id: str | None = None
1213
+ require_security_work_authorized: bool = False
1214
+ durable_capability_lineage_required: bool = False
1215
+ file_required_preferred_account: bool = False
1216
+ bridge_soft_capacity_reroute_allowed: bool = False
1217
+ error_code_override: str | None = None
1218
+ error_message_override: str | None = None
1219
+ error_type_override: str | None = None
1220
+ error_param_override: OpenAIErrorParam | JsonValue | None = None
1221
+ failure_phase_override: str | None = None
1222
+ failure_detail_override: str | None = None
1223
+ upstream_error_code_override: str | None = None
1224
+ error_http_status_override: int | None = None
1225
+ response_event_count: int = 0
1226
+ last_upstream_activity_at: float | None = None
1227
+ upstream_model_output_seen: bool = False
1228
+ # Terminal WebSocket error sanitization records continuity telemetry once;
1229
+ # later serializers preserve the normalized fields without recording it a
1230
+ # second time.
1231
+ websocket_terminal_error_fields_sanitized: bool = False
1232
+ previous_response_not_found_rewritten: bool = False
1233
+ # A canonical stale-anchor code with malformed present ``param`` may be
1234
+ # matched for masking, but must never authorize replay.
1235
+ previous_response_not_found_recovery_blocked: bool = False
1236
+ previous_response_owner_lookup_source: str | None = None
1237
+ previous_response_owner_lookup_outcome: str | None = None
1238
+ previous_response_owner_requested_at: datetime | None = None
1239
+ previous_response_owner_session_id: str | None = None
1240
+ # Subscription account proven to own ``previous_response_id`` by the
1241
+ # continuity index/request-log lookup. Identifier syntax is never used as
1242
+ # an ownership signal, so ``None`` leaves configured source routing intact.
1243
+ previous_response_owner_account_id: str | None = None
1244
+ response_create_gate_acquired: bool = False
1245
+ response_create_gate: asyncio.Semaphore | None = None
1246
+ response_create_admission: AdmissionLease | None = None
1247
+ response_create_admission_reacquire_required: bool = False
1248
+ account_response_create_lease: AccountLease | None = None
1249
+ account_response_create_release: Callable[[AccountLease | None], Coroutine[Any, Any, None]] | None = None
1250
+ websocket_stream_lease: AccountLease | None = None
1251
+ affinity_policy: _AffinityPolicy = field(default_factory=_AffinityPolicy)
1252
+ affinity_observation: AffinityObservation | None = None
1253
+ thread_affinity_last_touch_at: float = field(default_factory=time.monotonic)
1254
+ suppressed_duplicate_tool_call: bool = False
1255
+ pending_function_call_ids: list[str] = field(default_factory=list)
1256
+ pending_tool_call_types: dict[str, str] = field(default_factory=dict)
1257
+ added_tool_call_types: dict[str, str] = field(default_factory=dict)
1258
+ tool_call_manifest_invalid: bool = False
1259
+ seen_tool_call_keys: dict[ToolCallDedupeKey, None] = field(default_factory=dict)
1260
+ input_item_count: int = 0
1261
+ input_full_fingerprint: str | None = None
1262
+ api_key_reservation_last_touch_at: float = field(default_factory=time.monotonic)
1263
+ api_key_reservation_heartbeat_stop: asyncio.Event | None = None
1264
+ api_key_reservation_heartbeat_task: asyncio.Task[None] | None = None
1265
+ upstream_proxy_route_mode: str | None = None
1266
+ upstream_proxy_pool_id: str | None = None
1267
+ upstream_proxy_endpoint_id: str | None = None
1268
+ upstream_proxy_fallback_used: bool | None = None
1269
+ upstream_proxy_fail_closed_reason: str | None = None
1270
+ useragent: str | None = None
1271
+ useragent_group: str | None = None
1272
+ conversation_id: str | None = None
1273
+ # The Responses payload's own ``conversation``, distinct from the client
1274
+ # log's ``conversation_id`` above. It is account-scoped continuity with no
1275
+ # dedicated owner index, so the raw path cannot prove the bridge session's
1276
+ # owner for it.
1277
+ payload_conversation_bound: bool = False
1278
+ client_ip: str | None = None
1279
+ downstream_visible: bool = False
1280
+ last_downstream_sequence_number: int | None = None
1281
+ # Confirmed pre-dispatch account-route failures must not mutate account
1282
+ # health while this request's API-key reservation is still live. The
1283
+ # account objects are keyed by id so repeated connect attempts cannot
1284
+ # stack the same backoff floor more than once before settlement.
1285
+ deferred_account_error_backoffs: dict[str, Account] = field(default_factory=dict)
1286
+ deferred_account_backoff_tracker: _DeferredAccountBackoffTracker | None = None
1287
+ deferred_account_backoff_lifecycle: _DeferredAccountBackoffLifecycle | None = None
1288
+ # Classified health writes (mark_rate_limit / mark_quota_exceeded /
1289
+ # record_error) deferred by keyed pre-created retry branches until this
1290
+ # request's API-key reservation settles or its fallback release commits
1291
+ # (settlement-ordering invariant). Entries drop unapplied when neither
1292
+ # confirms.
1293
+ deferred_keyed_stream_health: list[_DeferredKeyedStreamHealthPenalty] = field(default_factory=list)
1294
+ deferred_reasoning_downstream_texts: list[str] = field(default_factory=list)
1295
+ suppress_next_created_downstream: bool = False
1296
+ # Armed together with the created suppression when the client already saw
1297
+ # ``response.in_progress`` from the failed attempt, so the replay's
1298
+ # duplicate prelude frame is dropped and the lifecycle stays single.
1299
+ suppress_next_in_progress_downstream: bool = False
1300
+ replay_downstream_response_id: str | None = None
1301
+ draining_until_terminal: bool = False
1302
+ completed_delivery_scope: _HTTPBridgeCompletedDeliveryScope | None = None
1303
+ # Exactly-once reservation settlement for terminal HTTP bridge events
1304
+ # (issue #1594). "claimed" marks a request popped from pending ownership
1305
+ # whose in-flight terminal bookkeeping continuation exclusively owns
1306
+ # settlement; "abandoned" marks a claim whose continuation aborted and
1307
+ # whose shielded abort settlement also failed, allowing a later detach to
1308
+ # reclaim settlement; None means unclaimed or settled.
1309
+ terminal_settlement_phase: Literal["claimed", "abandoned"] | None = None
1310
+ account_capacity_waiting: bool = False
1311
+ account_capacity_wait_suppress_keepalive: bool = False
1312
+ account_capacity_wait_reason: str | None = None
1313
+ account_capacity_wait_started_at: float | None = None
1314
+ account_capacity_wait_retry_after_seconds: float | None = None
1315
+ capacity_startup_wait_event: asyncio.Event | None = None
1316
+ capacity_startup_ready_event: asyncio.Event | None = None
1317
+
1318
+
1319
+ @dataclass(frozen=True, slots=True)
1320
+ class _HTTPBridgeSessionKey:
1321
+ affinity_kind: str
1322
+ affinity_key: str
1323
+ api_key_id: str | None
1324
+ strength: Literal["hard", "soft"] | None = None
1325
+
1326
+ def __post_init__(self) -> None:
1327
+ strength = self.strength
1328
+ if strength is None:
1329
+ strength = "hard" if self.affinity_kind in _HARD_HTTP_BRIDGE_AFFINITY_KINDS else "soft"
1330
+ object.__setattr__(self, "strength", strength)
1331
+
1332
+
1333
+ @dataclass(frozen=True, slots=True)
1334
+ class _HTTPBridgeOwnerForward:
1335
+ owner_instance: str
1336
+ owner_endpoint: str
1337
+ key: _HTTPBridgeSessionKey
1338
+
1339
+
1340
+ @dataclass(slots=True)
1341
+ class _HTTPBridgeSession:
1342
+ key: _HTTPBridgeSessionKey
1343
+ headers: dict[str, str]
1344
+ affinity: _AffinityPolicy
1345
+ request_model: str | None
1346
+ account: Account
1347
+ upstream: UpstreamWebSocket
1348
+ upstream_control: _WebSocketUpstreamControl
1349
+ pending_requests: deque[_WebSocketRequestState]
1350
+ pending_lock: anyio.Lock
1351
+ response_create_gate: asyncio.Semaphore
1352
+ queued_request_count: int
1353
+ last_used_at: float
1354
+ idle_ttl_seconds: float
1355
+ # Wakes a reader that began an unbounded receive before the first request
1356
+ # was enqueued. The reader keeps one receive task alive across wakeups.
1357
+ upstream_reader_wakeup: asyncio.Event = field(default_factory=asyncio.Event)
1358
+ unanchored_reservation_id: str | None = None
1359
+ admission_waiter_count: int = 0
1360
+ request_service_tier: str | None = None
1361
+ catalog_omission_quota_admission: CatalogOmissionQuotaAdmission | None = None
1362
+ lifecycle_lock: anyio.Lock = field(default_factory=fast_lock)
1363
+ recovery_alias_lock: anyio.Lock = field(default_factory=fast_lock)
1364
+ api_key: ApiKeyData | None = None
1365
+ codex_session: bool = False
1366
+ prewarmed: bool = False
1367
+ access_token_expires_at: float | None = None
1368
+ prewarm_lock: anyio.Lock | None = None
1369
+ upstream_turn_state: str | None = None
1370
+ downstream_turn_state: str | None = None
1371
+ downstream_turn_state_aliases: set[str] = field(default_factory=set)
1372
+ previous_response_ids: set[str] = field(default_factory=set)
1373
+ # The live session keeps only its current denial tombstone. Historical
1374
+ # ids remain in the process-local fence ledger while prepared requests pin
1375
+ # them, so this carrier cannot grow with every denied sibling anchor.
1376
+ denied_proxy_injected_anchor_ids: set[str] = field(default_factory=set)
1377
+ # Denials whose durable or local alias cleanup still needs a retry. This
1378
+ # is separate from the current tombstone carrier: a sibling-advanced
1379
+ # denial remains fenced for pinned requests, but is not unresolved cleanup
1380
+ # and may be retired when the session closes.
1381
+ denied_proxy_injected_anchor_cleanup_pending: set[str] = field(default_factory=set)
1382
+ denied_proxy_injected_anchor_generation: int = 0
1383
+ alias_registration_generation: int = 0
1384
+ turn_state_alias_registration_generations: dict[str, int] = field(default_factory=dict)
1385
+ previous_response_alias_registration_generations: dict[str, int] = field(default_factory=dict)
1386
+ last_completed_input_count: int = 0
1387
+ last_completed_response_id: str | None = None
1388
+ # Account that owns ``last_completed_response_id``. A previous_response_id
1389
+ # anchor is account-scoped upstream, so it may only be replayed on the same
1390
+ # account; when the session fails over to a different account this diverges
1391
+ # from ``account.id`` and the anchor must NOT be injected. Kept in sync with
1392
+ # ``last_completed_response_id`` at every setter.
1393
+ last_completed_response_account_id: str | None = None
1394
+ last_completed_input_prefix_fingerprint: str | None = None
1395
+ last_pending_tool_calls: dict[str, str] = field(default_factory=dict)
1396
+ durable_session_id: str | None = None
1397
+ durable_owner_epoch: int | None = None
1398
+ upstream_reader: asyncio.Task[None] | None = None
1399
+ last_upstream_event_generation: int = 0
1400
+ last_upstream_close_code: int | None = None
1401
+ last_upstream_close_generation: int = 0
1402
+ closed: bool = False
1403
+ # ``closed`` is only an admission fence. Resource teardown is single-flight
1404
+ # through this task so invalidation and shutdown can distinguish a rejected
1405
+ # session from one whose socket and leases actually have a close owner.
1406
+ resource_close_task: asyncio.Task[None] | None = None
1407
+ # ``closed`` rejects new admissions but is written by many unrelated
1408
+ # retirement paths; it never proves that a sender owns pending settlement.
1409
+ # Only the submitter may claim this, while holding ``lifecycle_lock``, when
1410
+ # its own send reports a liveness timeout. The reader remains the default
1411
+ # settlement owner for every other close, including an already-closed
1412
+ # session whose still-running transport later loses heartbeat liveness.
1413
+ liveness_settlement_owner: Literal["send"] | None = None
1414
+ # Set when the session proved silent/wedged (reattached stream with
1415
+ # response events but no ``response.created``, or repeated eventless
1416
+ # timeouts). A quarantined session must never be selected for reuse or
1417
+ # re-attach; later requests take the fresh session/no-anchor path.
1418
+ quarantined: bool = False
1419
+ # Set while a reader handoff is replacing the socket. Idle pruning must
1420
+ # retain the registered session during this short transition even though
1421
+ # ``closed`` is fail-closed for normal request reuse.
1422
+ handoff_in_progress: bool = False
1423
+ handoff_future: asyncio.Future["_HTTPBridgeSession"] | None = None
1424
+ account_lease: AccountLease | None = None
1425
+ upstream_close_attempted: bool = False
1426
+ seen_tool_call_keys: dict[ToolCallDedupeKey, None] = field(default_factory=dict)
1427
+ upstream_proxy_route_mode: str | None = None
1428
+ upstream_proxy_pool_id: str | None = None
1429
+ upstream_proxy_endpoint_id: str | None = None
1430
+ upstream_proxy_fallback_used: bool | None = None
1431
+ upstream_proxy_fail_closed_reason: str | None = None
1432
+ # Upstream frames that proved transport liveness but matched no pending
1433
+ # request. They are dropped from the downstream queues, so without this
1434
+ # counter a pre-response bridge timeout cannot tell "upstream said nothing"
1435
+ # apart from "upstream spoke and our matching lost the frame".
1436
+ unmatched_upstream_liveness_count: int = 0
1437
+
1438
+ def replace_connection(
1439
+ self,
1440
+ account: Account,
1441
+ headers: dict[str, str],
1442
+ upstream: UpstreamWebSocket,
1443
+ access_token_expires_at: float | None,
1444
+ ) -> None:
1445
+ """Replace account-bound transport state as one in-memory operation."""
1446
+ self.account = account
1447
+ self.headers = headers
1448
+ self.upstream = upstream
1449
+ self.access_token_expires_at = access_token_expires_at
1450
+
1451
+ def claim_liveness_settlement(self) -> bool:
1452
+ """Claim whole-deque settlement for a liveness-failed submitter.
1453
+
1454
+ The caller must hold ``lifecycle_lock`` across the failing send and
1455
+ this synchronous claim so the reader cannot settle the same deque.
1456
+ """
1457
+
1458
+ if self.liveness_settlement_owner is None:
1459
+ self.liveness_settlement_owner = "send"
1460
+ return self.liveness_settlement_owner == "send"
1461
+
1462
+
1463
+ def _complete_http_bridge_handoff(
1464
+ session: _HTTPBridgeSession,
1465
+ inflight_sessions: dict[_HTTPBridgeSessionKey, asyncio.Future[_HTTPBridgeSession]],
1466
+ ) -> None:
1467
+ session.handoff_in_progress = False
1468
+ future = session.handoff_future
1469
+ session.handoff_future = None
1470
+ if future is not None and not future.done():
1471
+ future.set_result(session)
1472
+ if inflight_sessions.get(session.key) is future:
1473
+ inflight_sessions.pop(session.key, None)
1474
+
1475
+
1476
+ def _http_bridge_session_supports_service_tier(
1477
+ session: _HTTPBridgeSession,
1478
+ *,
1479
+ request_model: str | None,
1480
+ request_service_tier: str | None,
1481
+ ) -> bool:
1482
+ if request_model is None:
1483
+ return True
1484
+
1485
+ registry = get_model_registry()
1486
+ # Mirror select_account: only apply model-account filtering when the model has
1487
+ # registry plan-presence. An operator-mapped but unadvertised slug yields an
1488
+ # authoritative-empty account catalog, which must not deny bridge session reuse.
1489
+ # Explicitly suppressed catalog slugs must, however, not allow reuse because
1490
+ # they intentionally block account selection even when the catalog is empty.
1491
+ plan_types_for_model = getattr(registry, "plan_types_for_model", None)
1492
+ model_allowed_plans = plan_types_for_model(request_model) if callable(plan_types_for_model) else None
1493
+ is_suppressed_model = getattr(registry, "is_suppressed_model", None)
1494
+ if callable(plan_types_for_model) and not model_allowed_plans:
1495
+ if callable(is_suppressed_model) and is_suppressed_model(request_model):
1496
+ return False
1497
+ return True
1498
+ account_indexes_cover_owner = True
1499
+ current_account_plan: str | None = None
1500
+ current_account_plan_is_authoritative = False
1501
+ get_snapshot = getattr(registry, "get_snapshot", None)
1502
+ if callable(get_snapshot):
1503
+ snapshot = get_snapshot()
1504
+ current_account_plan_is_authoritative = snapshot is not None and session.account.id in snapshot.account_plans
1505
+ account_indexes_cover_owner = current_account_plan_is_authoritative
1506
+ if current_account_plan_is_authoritative:
1507
+ current_account_plan = snapshot.account_plans[session.account.id]
1508
+ account_ids_for_model = getattr(registry, "account_ids_for_model", None)
1509
+ model_account_ids = (
1510
+ account_ids_for_model(request_model)
1511
+ if callable(account_ids_for_model) and account_indexes_cover_owner
1512
+ else None
1513
+ )
1514
+ model_catalog_omits_account = model_account_ids is not None and session.account.id not in model_account_ids
1515
+ quota_admission_matches = (
1516
+ session.catalog_omission_quota_admission is not None
1517
+ and session.catalog_omission_quota_admission.matches(
1518
+ requested_model=request_model,
1519
+ service_tier=request_service_tier,
1520
+ )
1521
+ )
1522
+ if model_catalog_omits_account and not quota_admission_matches:
1523
+ return False
1524
+ # Keep bridge reuse aligned with account selection: clients commonly send
1525
+ # these omit-equivalent values explicitly, but neither selects a specific
1526
+ # service tier.
1527
+ normalized_service_tier = request_service_tier.strip().lower() if request_service_tier is not None else None
1528
+ if normalized_service_tier in {None, "auto", "default"}:
1529
+ allowed_plans = model_allowed_plans
1530
+ else:
1531
+ allowed_account_ids = (
1532
+ registry.account_ids_for_model_service_tier(request_model, request_service_tier)
1533
+ if account_indexes_cover_owner
1534
+ else None
1535
+ )
1536
+ if allowed_account_ids is not None and not model_catalog_omits_account:
1537
+ return session.account.id in allowed_account_ids
1538
+
1539
+ allowed_plans = registry.plan_types_for_model_service_tier(request_model, request_service_tier)
1540
+ if allowed_plans is None:
1541
+ return True
1542
+ if current_account_plan_is_authoritative:
1543
+ account_plan = current_account_plan
1544
+ elif session.catalog_omission_quota_admission is not None:
1545
+ # Quota admission proves model support, not the owner's current plan eligibility.
1546
+ return False
1547
+ else:
1548
+ account_plan = session.account.plan_type
1549
+ return account_plan_matches_allowed(account_plan, allowed_plans)
1550
+
1551
+
1552
+ @dataclass(slots=True)
1553
+ class _WebSocketContinuityState:
1554
+ last_completed_input_count: int = 0
1555
+ last_completed_response_id: str | None = None
1556
+ last_completed_input_prefix_fingerprint: str | None = None
1557
+ last_pending_function_call_ids: list[str] = field(default_factory=list)
1558
+ last_pending_tool_call_types: dict[str, str] = field(default_factory=dict)
1559
+ responses_lite_model: str | None = None
1560
+ responses_lite_response_id: str | None = None
1561
+
1562
+
1563
+ @dataclass(frozen=True, slots=True)
1564
+ class _WebSocketContinuityAnchor:
1565
+ previous_response_id: str
1566
+ stored_input_item_count: int
1567
+
1568
+
1569
+ @dataclass(slots=True)
1570
+ class _WebSocketUpstreamControl:
1571
+ reconnect_requested: bool = False
1572
+ retire_after_drain: bool = False
1573
+ suppress_downstream_event: bool = False
1574
+ replay_request_state: _WebSocketRequestState | None = None
1575
+ downstream_texts: list[str] | None = None
1576
+ downstream_sequence_request_state: _WebSocketRequestState | None = None
1577
+ downstream_sequence_number: int | None = None
1578
+ seen_tool_call_keys: dict[ToolCallDedupeKey, None] = field(default_factory=dict)
1579
+ terminal_message_task: asyncio.Task[bool] | None = None
1580
+
1581
+
1582
+ @dataclass(slots=True)
1583
+ class _DownstreamWebSocketActivity:
1584
+ """Downstream websocket liveness stamps, read with the owner's clock.
1585
+
1586
+ ``clock`` is the owner's collaborator (``clock_for(proxy)``); the real
1587
+ default keeps the previous ``time.monotonic`` stamps verbatim.
1588
+ """
1589
+
1590
+ clock: Clock = field(default=REAL_CLOCK, kw_only=True)
1591
+ last_activity_at: float = field(init=False)
1592
+ disconnected: bool = False
1593
+
1594
+ def __post_init__(self) -> None:
1595
+ self.last_activity_at = self.clock.monotonic()
1596
+
1597
+ def mark(self) -> None:
1598
+ self.last_activity_at = self.clock.monotonic()
1599
+
1600
+ def mark_disconnected(self) -> None:
1601
+ self.disconnected = True
1602
+ self.mark()
1603
+
1604
+
1605
+ def _clear_websocket_request_error_overrides(request_state: _WebSocketRequestState) -> None:
1606
+ request_state.error_code_override = None
1607
+ request_state.error_message_override = None
1608
+ request_state.error_type_override = None
1609
+ request_state.error_param_override = None
1610
+ request_state.failure_phase_override = None
1611
+ request_state.failure_detail_override = None
1612
+ request_state.upstream_error_code_override = None
1613
+ request_state.error_http_status_override = None
1614
+
1615
+
1616
+ def _clear_websocket_precreated_replay_fallback(request_state: _WebSocketRequestState) -> None:
1617
+ if request_state.precreated_replay_reason != _ACCOUNT_MODEL_UNSUPPORTED_ERROR_CODE:
1618
+ return
1619
+ request_state.precreated_replay_reason = None
1620
+ request_state.precreated_replay_account_id = None
1621
+ _clear_websocket_request_error_overrides(request_state)
1622
+
1623
+
1624
+ def _websocket_is_reasoning_output_item_event(event_type: str | None, payload: Mapping[str, JsonValue] | None) -> bool:
1625
+ if event_type not in {"response.output_item.added", "response.output_item.done"} or payload is None:
1626
+ return False
1627
+ item = payload.get("item")
1628
+ return isinstance(item, Mapping) and item.get("type") == "reasoning"
1629
+
1630
+
1631
+ def _websocket_should_defer_reasoning_prelude(
1632
+ request_state: _WebSocketRequestState | None,
1633
+ event_type: str | None,
1634
+ payload: Mapping[str, JsonValue] | None,
1635
+ ) -> bool:
1636
+ if request_state is None:
1637
+ return False
1638
+ if request_state.downstream_visible:
1639
+ return False
1640
+ reasoning_output_item_event = _websocket_is_reasoning_output_item_event(event_type, payload)
1641
+ # Once a reasoning prelude starts, keep the whole prelude buffered. The
1642
+ # first reasoning item marks upstream model output as seen so replay stays
1643
+ # disabled; that marker must not make subsequent reasoning deltas visible.
1644
+ if request_state.deferred_reasoning_downstream_texts and (
1645
+ reasoning_output_item_event
1646
+ or event_type
1647
+ in {
1648
+ "response.reasoning_text.delta",
1649
+ "response.reasoning_text.done",
1650
+ "response.reasoning_summary_text.delta",
1651
+ "response.reasoning_summary_text.done",
1652
+ }
1653
+ ):
1654
+ return True
1655
+ if not reasoning_output_item_event:
1656
+ return False
1657
+ if request_state.upstream_model_output_seen:
1658
+ return False
1659
+ if request_state.pending_function_call_ids or request_state.pending_tool_call_types:
1660
+ return False
1661
+ return request_state.response_event_count <= 1
1662
+
1663
+
1664
+ def _pop_websocket_deferred_reasoning_downstream_texts(request_state: _WebSocketRequestState | None) -> list[str]:
1665
+ if request_state is None or not request_state.deferred_reasoning_downstream_texts:
1666
+ return []
1667
+ deferred_texts = request_state.deferred_reasoning_downstream_texts
1668
+ request_state.deferred_reasoning_downstream_texts = []
1669
+ return deferred_texts
1670
+
1671
+
1672
+ def _clear_websocket_deferred_reasoning_downstream_texts(request_state: _WebSocketRequestState | None) -> None:
1673
+ if request_state is not None:
1674
+ request_state.deferred_reasoning_downstream_texts = []
1675
+
1676
+
1677
+ def _mark_response_create_attempt_observed(
1678
+ request_state: _WebSocketRequestState | None,
1679
+ event_type: str | None,
1680
+ ) -> None:
1681
+ if request_state is None or event_type is None or not event_type.startswith("response."):
1682
+ return
1683
+ attempt = request_state.response_create_attempt
1684
+ if attempt is not None:
1685
+ attempt.response_observed = True
1686
+ if event_type not in {"response.failed", "response.incomplete"}:
1687
+ attempt.non_terminal_response_observed = True
1688
+
1689
+
1690
+ def _record_response_event(
1691
+ request_state: _WebSocketRequestState | None,
1692
+ event_type: str | None,
1693
+ *,
1694
+ now: float,
1695
+ ) -> None:
1696
+ if request_state is None or event_type is None or not event_type.startswith("response."):
1697
+ return
1698
+ _mark_response_create_attempt_observed(request_state, event_type)
1699
+ # ``now`` is the owner clock sample the bridge/websocket idle deadlines are
1700
+ # later compared against; callers pass ``clock_for(owner).monotonic()``.
1701
+ request_state.last_upstream_activity_at = now
1702
+ if event_type in {"response.failed", "response.incomplete"}:
1703
+ return
1704
+ request_state.response_event_count += 1
1705
+
1706
+
1707
+ def _websocket_request_can_replay_before_visible_output(
1708
+ request_state: _WebSocketRequestState,
1709
+ *,
1710
+ allow_clean_close_retry: bool = False,
1711
+ ) -> bool:
1712
+ if not request_state.request_text:
1713
+ return False
1714
+ if request_state.transport == _REQUEST_TRANSPORT_WEBSOCKET and request_state.response_create_sent_at is None:
1715
+ return False
1716
+ # The bounded clean-close retry is a pre-created affordance. An accepted
1717
+ # lifecycle (``replay_downstream_response_id`` captured) is re-sent exactly
1718
+ # once; a clean close of the replacement socket before its
1719
+ # ``response.created`` surfaces one terminal under the visible id instead
1720
+ # of a third send (openspec: retry-accepted-output-free-capacity-failures).
1721
+ if request_state.replay_count >= 1 and not (
1722
+ allow_clean_close_retry
1723
+ and request_state.replay_count == 1
1724
+ and request_state.response_event_count == 0
1725
+ and request_state.clean_close_replay_count == 0
1726
+ and request_state.replay_downstream_response_id is None
1727
+ ):
1728
+ return False
1729
+ # A sequenced downstream frame pins the request to its socket: a fresh
1730
+ # upstream generation restarts ``sequence_number`` from zero (openspec
1731
+ # requirement "Direct WebSocket replay never mixes numeric response
1732
+ # sequences"). The accepted-lifecycle replay does not widen this; the
1733
+ # created-only ``generate: false`` prewarm keeps its watermark-0 exception.
1734
+ sequenced_created_only_prewarm = (
1735
+ request_state.generate_false_prewarm
1736
+ and request_state.last_downstream_sequence_number == 0
1737
+ and request_state.response_id is not None
1738
+ and not request_state.awaiting_response_created
1739
+ and request_state.response_event_count == 1
1740
+ and not request_state.downstream_visible
1741
+ )
1742
+ if request_state.last_downstream_sequence_number is not None and not sequenced_created_only_prewarm:
1743
+ return False
1744
+ if request_state.downstream_visible:
1745
+ return False
1746
+ if request_state.upstream_model_output_seen:
1747
+ return False
1748
+ has_retry_safe_fresh_payload = (
1749
+ request_state.fresh_upstream_request_is_retry_safe and request_state.fresh_upstream_request_text is not None
1750
+ )
1751
+ precreated_pending = request_state.response_id is None and request_state.awaiting_response_created
1752
+ if precreated_pending and request_state.previous_response_id is not None and not has_retry_safe_fresh_payload:
1753
+ return False
1754
+ accepted_lifecycle_only_pending = _websocket_request_is_accepted_lifecycle_only(request_state) and (
1755
+ request_state.previous_response_id is None or has_retry_safe_fresh_payload
1756
+ )
1757
+ if precreated_pending and request_state.response_event_count > 0:
1758
+ return False
1759
+ return precreated_pending or accepted_lifecycle_only_pending
1760
+
1761
+
1762
+ def _websocket_request_is_accepted_lifecycle_only(request_state: _WebSocketRequestState) -> bool:
1763
+ """Return whether upstream accepted the request without producing any output.
1764
+
1765
+ True only while the accepted response consists of its lifecycle prelude:
1766
+ ``response.created`` and optionally ``response.in_progress``. In the
1767
+ Responses protocol every other counted ``response.*`` event follows
1768
+ ``response.output_item.added``, which is a model-output event
1769
+ (``_MODEL_OUTPUT_EVENT_TYPES``) that both the HTTP bridge and the direct
1770
+ websocket relay record in ``upstream_model_output_seen``, so "at most two
1771
+ counted events and no output marker" is equivalent to "lifecycle only"
1772
+ even when upstream skips ``response.in_progress``. A buffered reasoning
1773
+ prelude or a pending tool call disqualifies the request. Such a turn has
1774
+ no client-observable or conversational side effect, which is what makes
1775
+ its single-lifecycle replay safe.
1776
+
1777
+ This predicate describes the upstream lifecycle only. Downstream sequence
1778
+ exposure (``last_downstream_sequence_number``) is judged by the callers:
1779
+ ``_websocket_request_can_replay_before_visible_output`` and the accepted
1780
+ capacity classifier both keep refusing a sequenced request, so the direct
1781
+ websocket surface never replays after a finite ``sequence_number`` frame
1782
+ was forwarded.
1783
+ """
1784
+ if request_state.response_id is None or request_state.awaiting_response_created:
1785
+ return False
1786
+ if not 1 <= request_state.response_event_count <= 2:
1787
+ return False
1788
+ if request_state.downstream_visible or request_state.upstream_model_output_seen:
1789
+ return False
1790
+ if request_state.deferred_reasoning_downstream_texts:
1791
+ return False
1792
+ return not (request_state.pending_function_call_ids or request_state.pending_tool_call_types)
1793
+
1794
+
1795
+ def _affinity_may_resolve_hard_owner(affinity_policy: _AffinityPolicy) -> bool:
1796
+ """Return whether sticky selection may bind this affinity to one owner account.
1797
+
1798
+ A resolved hard ``CODEX_SESSION`` row narrows selection to its owner
1799
+ (``hard_sticky`` in ``sticky_selection``): turn-state ownership, or the raw
1800
+ compatibility row an old replica persisted for a bare session or thread
1801
+ header, which every policy exposing ``legacy_selection_key`` consults and
1802
+ which wins over the namespaced soft row. The owner is not a request-state
1803
+ pin -- it is read from the database at selection time -- so neither the
1804
+ direct websocket request nor the HTTP bridge session can tell whether its
1805
+ affinity resolves to a hard owner. Any policy that may is treated as
1806
+ owner-bound: excluding that owner would leave every re-selection at
1807
+ ``hard_affinity_saturated`` until the connect budget runs out. Shared by
1808
+ the direct websocket accepted-replay exclusion and the HTTP bridge one.
1809
+ """
1810
+ return (
1811
+ affinity_policy.kind == StickySessionKind.CODEX_SESSION
1812
+ or affinity_policy.legacy_selection_key is not None
1813
+ or affinity_policy.legacy_continuity_source is not None
1814
+ )
1815
+
1816
+
1817
+ def _record_websocket_route_metadata(
1818
+ request_state: _WebSocketRequestState,
1819
+ *,
1820
+ upstream: UpstreamWebSocket | None = None,
1821
+ route: ResolvedUpstreamRoute | None = None,
1822
+ fallback_used: bool | None = None,
1823
+ ) -> None:
1824
+ request_state.upstream_proxy_route_mode = getattr(upstream, "upstream_proxy_route_mode", None) or (
1825
+ route.mode if route is not None else None
1826
+ )
1827
+ request_state.upstream_proxy_pool_id = getattr(upstream, "upstream_proxy_pool_id", None) or (
1828
+ route.pool_id if route is not None else None
1829
+ )
1830
+ request_state.upstream_proxy_endpoint_id = getattr(upstream, "upstream_proxy_endpoint_id", None) or (
1831
+ route.endpoint_id if route is not None else None
1832
+ )
1833
+ upstream_fallback = getattr(upstream, "upstream_proxy_fallback_used", None)
1834
+ request_state.upstream_proxy_fallback_used = upstream_fallback if upstream_fallback is not None else fallback_used
1835
+ if request_state.upstream_proxy_endpoint_id is None:
1836
+ request_state.upstream_proxy_fallback_used = None
1837
+ request_state.upstream_proxy_fail_closed_reason = None
1838
+
1839
+
1840
+ def _copy_websocket_route_metadata_to_session(
1841
+ session: _HTTPBridgeSession,
1842
+ request_state: _WebSocketRequestState,
1843
+ ) -> None:
1844
+ session.upstream_proxy_route_mode = request_state.upstream_proxy_route_mode
1845
+ session.upstream_proxy_pool_id = request_state.upstream_proxy_pool_id
1846
+ session.upstream_proxy_endpoint_id = request_state.upstream_proxy_endpoint_id
1847
+ session.upstream_proxy_fallback_used = request_state.upstream_proxy_fallback_used
1848
+ session.upstream_proxy_fail_closed_reason = request_state.upstream_proxy_fail_closed_reason
1849
+
1850
+
1851
+ def _copy_websocket_route_metadata_from_session(
1852
+ request_state: _WebSocketRequestState,
1853
+ session: _HTTPBridgeSession,
1854
+ ) -> None:
1855
+ request_state.upstream_proxy_route_mode = session.upstream_proxy_route_mode
1856
+ request_state.upstream_proxy_pool_id = session.upstream_proxy_pool_id
1857
+ request_state.upstream_proxy_endpoint_id = session.upstream_proxy_endpoint_id
1858
+ request_state.upstream_proxy_fallback_used = session.upstream_proxy_fallback_used
1859
+ request_state.upstream_proxy_fail_closed_reason = session.upstream_proxy_fail_closed_reason
1860
+
1861
+
1862
+ def _websocket_route_log_kwargs(request_state: _WebSocketRequestState) -> dict[str, str | bool | None]:
1863
+ return {
1864
+ "upstream_proxy_route_mode": request_state.upstream_proxy_route_mode,
1865
+ "upstream_proxy_pool_id": request_state.upstream_proxy_pool_id,
1866
+ "upstream_proxy_endpoint_id": request_state.upstream_proxy_endpoint_id,
1867
+ "upstream_proxy_fallback_used": (
1868
+ request_state.upstream_proxy_fallback_used if request_state.upstream_proxy_endpoint_id else None
1869
+ ),
1870
+ "upstream_proxy_fail_closed_reason": request_state.upstream_proxy_fail_closed_reason,
1871
+ }
1872
+
1873
+
1874
+ @dataclass(slots=True)
1875
+ class _PreparedWebSocketRequest:
1876
+ text_data: str
1877
+ request_state: _WebSocketRequestState
1878
+ affinity_policy: _AffinityPolicy
1879
+
1880
+
1881
+ @dataclass(frozen=True, slots=True)
1882
+ class _WebSocketReceiveTimeout:
1883
+ timeout_seconds: float
1884
+ error_code: str
1885
+ error_message: str
1886
+ fail_all_pending: bool = False
1887
+
1888
+
1889
+ def _event_type_from_payload(event: OpenAIEvent | None, payload: dict[str, JsonValue] | None) -> str | None:
1890
+ if event is not None:
1891
+ return event.type
1892
+ return classify_event_type(payload)
1893
+
1894
+
1895
+ async def _wait_for_websocket_continuity_gap(
1896
+ pending_requests: deque[_WebSocketRequestState],
1897
+ *,
1898
+ pending_lock: anyio.Lock,
1899
+ timeout_seconds: float,
1900
+ scheduler: Scheduler,
1901
+ clock: Clock,
1902
+ ) -> bool:
1903
+ deadline = clock.monotonic() + max(0.0, timeout_seconds)
1904
+ while True:
1905
+ async with pending_lock:
1906
+ if not pending_requests:
1907
+ return True
1908
+ remaining = deadline - clock.monotonic()
1909
+ if remaining <= 0:
1910
+ return False
1911
+ await scheduler.sleep(min(_WEBSOCKET_FULL_REPLAY_WAIT_POLL_SECONDS, remaining))
1912
+
1913
+
1914
+ async def _websocket_full_replay_should_wait_for_continuity(
1915
+ request_state: _WebSocketRequestState,
1916
+ pending_requests: deque[_WebSocketRequestState],
1917
+ *,
1918
+ pending_lock: anyio.Lock,
1919
+ codex_session_affinity: bool,
1920
+ ) -> bool:
1921
+ if (
1922
+ not codex_session_affinity
1923
+ or request_state.previous_response_id is not None
1924
+ or request_state.input_item_count < _WEBSOCKET_FULL_REPLAY_WAIT_MIN_ITEMS
1925
+ ):
1926
+ return False
1927
+ async with pending_lock:
1928
+ return bool(pending_requests)
1929
+
1930
+
1931
+ def _is_account_neutral_error_code(code: str | None) -> bool:
1932
+ return is_local_overload_error_code(code) or code in {
1933
+ PROCESS_NETWORK_UNAVAILABLE_CODE,
1934
+ "proxy_unavailable",
1935
+ "responses_compact_input_too_large",
1936
+ "stream_idle_timeout",
1937
+ }
1938
+
1939
+
1940
+ def _is_local_account_cap_code(code: str | None) -> bool:
1941
+ return code in _LOCAL_ACCOUNT_CAP_ERROR_CODES
1942
+
1943
+
1944
+ def _api_key_fair_share_threshold_pct_from_settings(settings: object) -> int:
1945
+ """Resolve the fair-share congestion threshold, tolerating stale snapshots.
1946
+
1947
+ Cached settings snapshots taken before the field existed lack it; fall
1948
+ back to the live settings object the same way the stream recovery reserve
1949
+ resolution in ``ProxyService`` does.
1950
+ """
1951
+ threshold = getattr(settings, "proxy_api_key_fair_share_congestion_threshold_pct", None)
1952
+ if threshold is None:
1953
+ return get_settings().proxy_api_key_fair_share_congestion_threshold_pct
1954
+ return int(threshold)
1955
+
1956
+
1957
+ def _selection_api_key_fair_share_threshold_pct(
1958
+ settings: object,
1959
+ *,
1960
+ lease_kind: str | None,
1961
+ request_stage: str,
1962
+ ) -> int:
1963
+ """Fair-share threshold for one account selection; 0 disables the gate.
1964
+
1965
+ Only stream leases are fair-share gated. Reattach resumes an existing
1966
+ in-flight response; denying it would strand running work, so it bypasses
1967
+ the fair-share gate the same way it bypasses the recovery reserve.
1968
+ """
1969
+ if lease_kind != "stream" or request_stage == "reattach":
1970
+ return 0
1971
+ return _api_key_fair_share_threshold_pct_from_settings(settings)
1972
+
1973
+
1974
+ def opportunistic_admission_account_scope(settings: object, api_key: ApiKeyData | None) -> set[str] | None:
1975
+ """Account ids an opportunistic admission check may consider; ``None`` is the whole pool.
1976
+
1977
+ The API key's account-assignment scope applies first. Single-account
1978
+ routing then narrows the scope to the selected account, or to nothing when
1979
+ no account is selected or the selected one lies outside the key's scope.
1980
+ This is the exact scope ``ProxyService.check_opportunistic_admission`` has
1981
+ always applied, hoisted so read-only callers (the pool-exhaustion probe)
1982
+ share one definition with the admission gate.
1983
+ """
1984
+ scoped_account_ids = (
1985
+ set(api_key.assigned_account_ids) if api_key is not None and api_key.account_assignment_scope_enabled else None
1986
+ )
1987
+ if getattr(settings, "routing_strategy", None) != "single_account":
1988
+ return scoped_account_ids
1989
+ selected_account_id = (getattr(settings, "single_account_id", None) or "").strip()
1990
+ if not selected_account_id:
1991
+ return set()
1992
+ if scoped_account_ids is None or selected_account_id in scoped_account_ids:
1993
+ return {selected_account_id}
1994
+ return set()
1995
+
1996
+
1997
+ def _http_error_status_from_payload(payload: dict[str, JsonValue] | None) -> int | None:
1998
+ if not isinstance(payload, dict):
1999
+ return None
2000
+ for status_field in ("status", "status_code"):
2001
+ status = payload.get(status_field)
2002
+ if isinstance(status, int) and not isinstance(status, bool):
2003
+ return status
2004
+ return None
2005
+
2006
+
2007
+ def _openai_error_envelope_from_response_failed_payload(
2008
+ payload: dict[str, JsonValue] | None,
2009
+ ) -> OpenAIErrorEnvelope:
2010
+ default_envelope = openai_error("upstream_error", "Upstream error")
2011
+ if not isinstance(payload, dict):
2012
+ return default_envelope
2013
+ response_payload = payload.get("response")
2014
+ if not isinstance(response_payload, dict):
2015
+ return default_envelope
2016
+ error_payload = response_payload.get("error")
2017
+ if not isinstance(error_payload, dict):
2018
+ return default_envelope
2019
+
2020
+ message_value = error_payload.get("message")
2021
+ if isinstance(message_value, str) and message_value.strip():
2022
+ message = message_value.strip()
2023
+ else:
2024
+ message = "Upstream error"
2025
+
2026
+ code_value = error_payload.get("code")
2027
+ code = code_value.strip() if isinstance(code_value, str) and code_value.strip() else "upstream_error"
2028
+
2029
+ type_value = error_payload.get("type")
2030
+ error_type = type_value.strip() if isinstance(type_value, str) and type_value.strip() else "server_error"
2031
+
2032
+ envelope = openai_error(code, message, error_type)
2033
+ if "param" in error_payload:
2034
+ param_state = OpenAIErrorParam.from_mapping(cast(Mapping[str, JsonValue], error_payload))
2035
+ envelope["error"]["param"] = param_state.raw
2036
+ error_detail = envelope["error"]
2037
+ plan_type = error_payload.get("plan_type")
2038
+ if plan_type is not None:
2039
+ error_detail["plan_type"] = str(plan_type)
2040
+ resets_at = error_payload.get("resets_at")
2041
+ if isinstance(resets_at, int | float):
2042
+ error_detail["resets_at"] = resets_at
2043
+ resets_in = error_payload.get("resets_in_seconds")
2044
+ if isinstance(resets_in, int | float):
2045
+ error_detail["resets_in_seconds"] = resets_in
2046
+ return envelope
2047
+
2048
+
2049
+ # --- Upstream websocket transport health -----------------------------------
2050
+ #
2051
+ # Codex clients only switch to the HTTP transport when the websocket
2052
+ # handshake is rejected with HTTP 426 (codex-rs checks
2053
+ # ``StatusCode::UPGRADE_REQUIRED`` on connect; in-band error events never
2054
+ # trigger the transport fallback). This per-instance marker remembers a
2055
+ # recent connect-phase upstream websocket transport failure so the websocket
2056
+ # routes can deny the next handshake with 426 and the HTTP paths can pin the
2057
+ # upstream transport to HTTP until the websocket upstream proves healthy
2058
+ # again.
2059
+
2060
+ UPSTREAM_WS_TRANSPORT_FAILURE_TTL_SECONDS = 60.0
2061
+ _upstream_ws_transport_failure_at: float | None = None
2062
+
2063
+
2064
+ def mark_upstream_websocket_transport_failure() -> None:
2065
+ global _upstream_ws_transport_failure_at
2066
+ _upstream_ws_transport_failure_at = time.monotonic()
2067
+
2068
+
2069
+ def clear_upstream_websocket_transport_failure() -> None:
2070
+ global _upstream_ws_transport_failure_at
2071
+ _upstream_ws_transport_failure_at = None
2072
+
2073
+
2074
+ def websocket_connect_transport_failure_code(
2075
+ exc: ProxyResponseError,
2076
+ *,
2077
+ confirmed_pre_dispatch: bool,
2078
+ ) -> str | None:
2079
+ """Code of a host-scoped upstream websocket transport failure, else ``None``.
2080
+
2081
+ The discriminator is the provenance the direct upstream open stamps, not
2082
+ the sanitized error code. Codes cannot carry it in either direction: the
2083
+ responses policy preserves the upstream handshake body, so a direct 5xx
2084
+ upgrade rejection surfaces as ``upstream_error`` or whatever the edge
2085
+ returned, while OAuth refresh transport errors, routed-proxy handshakes,
2086
+ TLS verification failures and host-wide network loss all share the
2087
+ ``upstream_unavailable`` envelope and must keep their
2088
+ classify-penalize-failover handling.
2089
+ """
2090
+
2091
+ if confirmed_pre_dispatch or exc.failure_phase != "connect":
2092
+ return None
2093
+ if exc.failure_detail != UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL:
2094
+ return None
2095
+ connect_error = _parse_openai_error(exc.payload)
2096
+ return _normalize_error_code(
2097
+ connect_error.code if connect_error else None,
2098
+ connect_error.type if connect_error else None,
2099
+ )
2100
+
2101
+
2102
+ UPSTREAM_STREAM_TRANSPORTS = frozenset({"auto", "http", "websocket"})
2103
+ _UPSTREAM_STREAM_TRANSPORT_DEFAULT = "auto"
2104
+
2105
+
2106
+ def configured_upstream_stream_transport(dashboard_settings: Any) -> str:
2107
+ """Return the operator-configured upstream stream transport.
2108
+
2109
+ The dashboard row is the only source. The legacy ``"default"`` sentinel
2110
+ (which used to defer to the removed ``CODEX_LB_UPSTREAM_STREAM_TRANSPORT``
2111
+ env var) and any unknown value resolve to ``"auto"`` so a settings-cache
2112
+ snapshot taken before the data migration ran behaves like the migrated row.
2113
+ """
2114
+ configured = getattr(dashboard_settings, "upstream_stream_transport", None)
2115
+ if configured in UPSTREAM_STREAM_TRANSPORTS:
2116
+ return cast(str, configured)
2117
+ return _UPSTREAM_STREAM_TRANSPORT_DEFAULT
2118
+
2119
+
2120
+ def upstream_websocket_transport_recently_failed() -> bool:
2121
+ marked_at = _upstream_ws_transport_failure_at
2122
+ return marked_at is not None and time.monotonic() - marked_at < UPSTREAM_WS_TRANSPORT_FAILURE_TTL_SECONDS