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,1411 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import ssl
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from typing import Any, Mapping, NoReturn, Protocol, Sequence, cast
12
+ from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
13
+
14
+ import aiohttp
15
+ from websockets.asyncio.client import ClientConnection
16
+ from websockets.asyncio.client import connect as websocket_connect
17
+ from websockets.datastructures import Headers
18
+ from websockets.exceptions import (
19
+ ConnectionClosedError,
20
+ ConnectionClosedOK,
21
+ InvalidHandshake,
22
+ InvalidProxy,
23
+ InvalidStatus,
24
+ )
25
+ from websockets.typing import Origin, Subprotocol
26
+
27
+ from app.core.clients.codex import (
28
+ CodexClient,
29
+ CodexTransportError,
30
+ codex_transport_error_message,
31
+ create_codex_session,
32
+ require_route_or_direct_egress_opt_in,
33
+ )
34
+ from app.core.clients.http import _shared_system_ssl_context
35
+ from app.core.clients.native_egress import (
36
+ NativeEgressError,
37
+ NativeEgressTransportError,
38
+ NativeEgressUnavailable,
39
+ NativeEgressWebSocket,
40
+ NativeWebSocketRequest,
41
+ NativeWebSocketRoutingMetadata,
42
+ discover_native_egress_client,
43
+ )
44
+ from app.core.clients.proxy import (
45
+ _CHATGPT_ACCOUNT_ID_HEADER,
46
+ _HOP_BY_HOP_HEADER_NAMES,
47
+ CODEX_INSTALLATION_ID_HEADER,
48
+ CODEX_ROUTING_HINT_HEADER,
49
+ MAX_SSE_EVENT_BYTES,
50
+ ProxyResponseError,
51
+ _is_native_codex_request,
52
+ _is_upstream_edge_challenge,
53
+ _normalize_non_native_upstream_fingerprint,
54
+ _openai_error_detail,
55
+ filter_inbound_headers,
56
+ )
57
+ from app.core.config.dashboard_overrides import with_dashboard_overrides
58
+ from app.core.config.settings import get_settings
59
+ from app.core.conversation_archive import archive_bytes, archive_text
60
+ from app.core.errors import OpenAIErrorEnvelope, openai_error
61
+ from app.core.openai.parsing import parse_error_payload
62
+ from app.core.resilience.network_recovery import (
63
+ PROCESS_NETWORK_UNAVAILABLE_CODE,
64
+ process_network_error_code,
65
+ rotate_shared_http_transport,
66
+ )
67
+ from app.core.types import JsonValue
68
+ from app.core.upstream_proxy import ResolvedUpstreamRoute
69
+ from app.core.utils.proxy_env import resolve_websocket_proxy_from_env
70
+ from app.core.utils.request_id import get_request_id
71
+
72
+ _WEBSOCKET_HOP_BY_HOP_HEADERS = _HOP_BY_HOP_HEADER_NAMES | frozenset(
73
+ {
74
+ "accept-encoding",
75
+ "cookie",
76
+ "sec-websocket-extensions",
77
+ "sec-websocket-key",
78
+ "sec-websocket-protocol",
79
+ "sec-websocket-version",
80
+ }
81
+ )
82
+ _RESPONSES_WEBSOCKET_BETA_HEADER = "responses_websockets=2026-02-06"
83
+ _RESPONSES_WEBSOCKET_INCOMPATIBLE_BETA_HEADERS = frozenset({"responses=experimental"})
84
+ _OPENAI_LIVE_BASE_URL = "https://api.openai.com/v1"
85
+ _LIVE_CALL_ID_MAX_LENGTH = 256
86
+ _LIVE_CALL_ID_RTC_PREFIX = "rtc_"
87
+ # Keep the route convertor and normalizer on one grammar: total-length-capped rtc_
88
+ # ids using the installed-app character set, or a hyphenated UUID form.
89
+ _LIVE_CALL_ID_CHAR_CLASS = r"A-Za-z0-9._~\-"
90
+ _LIVE_CALL_UUID_CORE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
91
+ _LIVE_CALL_ID_RTC_MAX_SUFFIX_LENGTH = _LIVE_CALL_ID_MAX_LENGTH - len(_LIVE_CALL_ID_RTC_PREFIX)
92
+ REALTIME_LIVE_CALL_ID_ROUTE_REGEX = (
93
+ rf"(?:{_LIVE_CALL_ID_RTC_PREFIX}[{_LIVE_CALL_ID_CHAR_CLASS}]{{1,{_LIVE_CALL_ID_RTC_MAX_SUFFIX_LENGTH}}}"
94
+ rf"|{_LIVE_CALL_UUID_CORE})"
95
+ )
96
+ _LIVE_CALL_ID_PATTERN = re.compile(rf"{REALTIME_LIVE_CALL_ID_ROUTE_REGEX}\Z")
97
+ UPSTREAM_WEBSOCKET_LIVENESS_TIMEOUT_CODE = "upstream_websocket_liveness_timeout"
98
+ _WEBSOCKETS_KEEPALIVE_TIMEOUT_REASON = "keepalive ping timeout"
99
+ _AIOHTTP_HEARTBEAT_TIMEOUT_PREFIX = "No PONG received after "
100
+
101
+
102
+ class RealtimeWebSocketProtocol(StrEnum):
103
+ LIVE_V3 = "live_v3"
104
+ REALTIME_V1_V2 = "realtime_v1_v2"
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class _UpstreamWebSocketPolicy:
109
+ operation: str
110
+ include_responses_beta: bool
111
+ archive_payloads: bool
112
+ enable_routed_heartbeat: bool
113
+ retry_handshake_status: bool
114
+ preserve_handshake_status: bool
115
+ credential_safe_connect_errors: bool
116
+ retry_routed_network_errors: bool
117
+ enable_direct_ping_timeout: bool
118
+ preserve_close_semantics: bool
119
+
120
+
121
+ # Responses turns may be silent at the application layer for minutes, but a
122
+ # healthy transport still answers ping control frames. Keep both watchdogs on:
123
+ # disabling them turns a black-holed VPN route into a multi-hour request stall.
124
+ _RESPONSES_WEBSOCKET_POLICY = _UpstreamWebSocketPolicy(
125
+ operation="responses websocket",
126
+ include_responses_beta=True,
127
+ archive_payloads=True,
128
+ enable_routed_heartbeat=True,
129
+ retry_handshake_status=True,
130
+ preserve_handshake_status=False,
131
+ credential_safe_connect_errors=False,
132
+ retry_routed_network_errors=True,
133
+ enable_direct_ping_timeout=True,
134
+ preserve_close_semantics=False,
135
+ )
136
+ _LIVE_SIDEBAND_WEBSOCKET_POLICY = _UpstreamWebSocketPolicy(
137
+ operation="live websocket",
138
+ include_responses_beta=False,
139
+ archive_payloads=False,
140
+ enable_routed_heartbeat=True,
141
+ retry_handshake_status=False,
142
+ preserve_handshake_status=True,
143
+ credential_safe_connect_errors=True,
144
+ retry_routed_network_errors=False,
145
+ enable_direct_ping_timeout=True,
146
+ preserve_close_semantics=True,
147
+ )
148
+
149
+ # Provenance proving the upstream websocket transport itself failed to come
150
+ # up, as opposed to an account-, route-, or configuration-scoped connect
151
+ # failure that happens to share the sanitized ``upstream_unavailable``
152
+ # envelope. Only the direct upstream open can establish it: a routed open
153
+ # proves nothing beyond the health of that one account's proxy endpoint, and
154
+ # TLS verification failures and host-wide network loss are conditions that
155
+ # switching the request to raw HTTP cannot route around.
156
+ UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL = "upstream_websocket_transport_unavailable"
157
+
158
+
159
+ logger = logging.getLogger(__name__)
160
+
161
+
162
+ def normalize_realtime_call_id(value: str) -> str | None:
163
+ normalized = value.strip()
164
+ if not normalized or len(normalized) > _LIVE_CALL_ID_MAX_LENGTH:
165
+ return None
166
+ if _LIVE_CALL_ID_PATTERN.fullmatch(normalized) is None:
167
+ return None
168
+ if normalized.startswith(_LIVE_CALL_ID_RTC_PREFIX):
169
+ return normalized
170
+ return normalized.lower()
171
+
172
+
173
+ def _consume_connection_lost_exception(done: asyncio.Future[Any]) -> None:
174
+ """Retrieve close exceptions before websockets shields the waiter.
175
+
176
+ websockets 16 waits on ``connection_lost_waiter`` through
177
+ ``asyncio.shield`` while completing ``ClientConnection.recv``. A peer
178
+ keepalive/protocol close therefore leaves an exception on the waiter,
179
+ which asyncio reports as an ``exception in shielded future`` even though
180
+ ``recv`` translates it into an ``UpstreamWebSocketMessage``. Consume it
181
+ at the adapter boundary; ``receive`` still classifies the close normally.
182
+ """
183
+ if done.cancelled():
184
+ return
185
+ try:
186
+ done.exception()
187
+ except asyncio.CancelledError:
188
+ return
189
+
190
+
191
+ @dataclass(slots=True)
192
+ class UpstreamWebSocketMessage:
193
+ kind: str
194
+ text: str | None = None
195
+ data: bytes | None = None
196
+ close_code: int | None = None
197
+ close_reason: str | None = None
198
+ error: str | None = None
199
+ error_code: str | None = None
200
+ responses_interpreted: bool = False
201
+ event_type: str | None = None
202
+ payload: dict[str, JsonValue] | None = None
203
+ routing: NativeWebSocketRoutingMetadata | None = None
204
+
205
+
206
+ class UpstreamWebSocketTransportError(RuntimeError):
207
+ """Credential-safe post-connect transport failure with stable classification."""
208
+
209
+ def __init__(self, message: str, *, error_code: str) -> None:
210
+ super().__init__(message)
211
+ self.error_code = error_code
212
+
213
+
214
+ def _websocket_transport_error_code(exc: BaseException, *, uses_proxy: bool) -> str:
215
+ if _is_websocket_liveness_timeout(exc):
216
+ return UPSTREAM_WEBSOCKET_LIVENESS_TIMEOUT_CODE
217
+ return process_network_error_code(
218
+ exc,
219
+ fallback="upstream_unavailable",
220
+ include_permanent_dns=not uses_proxy,
221
+ )
222
+
223
+
224
+ def is_account_neutral_websocket_error_code(error_code: str | None) -> bool:
225
+ """Return whether transport provenance rules out an account-health penalty."""
226
+
227
+ # These failures occur below the selected account's application protocol.
228
+ # They follow an ambiguous send, so relay owners must fail rather than
229
+ # replay while leaving the account eligible for unrelated requests. Keep
230
+ # the compatibility keepalive code here as long as adapters can emit it.
231
+ return error_code in {
232
+ PROCESS_NETWORK_UNAVAILABLE_CODE,
233
+ UPSTREAM_WEBSOCKET_LIVENESS_TIMEOUT_CODE,
234
+ "upstream_keepalive_timeout",
235
+ }
236
+
237
+
238
+ def _is_websocket_liveness_timeout(exc: BaseException) -> bool:
239
+ if isinstance(exc, ConnectionClosedError):
240
+ # websockets emits this locally-sent 1011 when its own ping watchdog
241
+ # expires. A peer may acknowledge it, leaving both close frames on the
242
+ # exception; send-first ordering still proves the marker came from our
243
+ # watchdog without trusting a peer that sends the same code and reason.
244
+ return (
245
+ exc.sent is not None
246
+ and int(exc.sent.code) == 1011
247
+ and exc.sent.reason == _WEBSOCKETS_KEEPALIVE_TIMEOUT_REASON
248
+ and (exc.rcvd is None or exc.rcvd_then_sent is False)
249
+ )
250
+ # aiohttp surfaces its heartbeat watchdog through WSMsgType.ERROR with a
251
+ # ServerTimeoutError carrying this library-defined prefix.
252
+ return isinstance(exc, aiohttp.ServerTimeoutError) and str(exc).startswith(_AIOHTTP_HEARTBEAT_TIMEOUT_PREFIX)
253
+
254
+
255
+ def _aiohttp_stored_liveness_exception(websocket: Any) -> Exception | None:
256
+ # When aiohttp's heartbeat expires between receive() calls, no waiter is
257
+ # available for WSMsgType.ERROR. aiohttp stores the timeout instead and the
258
+ # next receive returns CLOSED, so every post-connect path must consult it.
259
+ exception_getter = getattr(websocket, "exception", None)
260
+ if not callable(exception_getter):
261
+ return None
262
+ exception = exception_getter()
263
+ return exception if isinstance(exception, Exception) and _is_websocket_liveness_timeout(exception) else None
264
+
265
+
266
+ def _relay_receive_error_code(error_code: str) -> str | None:
267
+ """Expose account-neutral transport failures across the adapter boundary."""
268
+
269
+ # Relay owners map an absent code to their established stream_incomplete
270
+ # contract. Leaking the adapter's generic fallback would bypass that path.
271
+ return error_code if is_account_neutral_websocket_error_code(error_code) else None
272
+
273
+
274
+ def _is_keepalive_timeout_close(exc: ConnectionClosedError) -> bool:
275
+ """Classify peer/proxy heartbeat failures without exposing socket details."""
276
+
277
+ # Treat the legacy text marker as trusted only when this endpoint initiated
278
+ # the close. A peer can send the same public code and reason, so peer-first
279
+ # ordering must retain ordinary close/error semantics.
280
+ if exc.sent is None or (exc.rcvd is not None and exc.rcvd_then_sent is not False):
281
+ return False
282
+ reason = _close_reason_from_exception(exc)
283
+ return "keepalive ping timeout" in f"{exc} {reason or ''}".lower()
284
+
285
+
286
+ async def _rotate_after_websocket_network_failure(error_code: str) -> None:
287
+ if error_code != PROCESS_NETWORK_UNAVAILABLE_CODE:
288
+ return
289
+ try:
290
+ await rotate_shared_http_transport(transport="websocket", request_id=get_request_id())
291
+ except Exception:
292
+ # Rotation is best-effort here: never replace the credential-safe
293
+ # socket failure that the owning request must surface.
294
+ logger.warning("Failed to rotate shared HTTP state after websocket network failure", exc_info=True)
295
+
296
+
297
+ async def _raise_websocket_send_error(
298
+ exc: Exception,
299
+ *,
300
+ endpoint_id: str | None = None,
301
+ uses_proxy: bool,
302
+ ) -> NoReturn:
303
+ error_code = _websocket_transport_error_code(exc, uses_proxy=uses_proxy)
304
+ await _rotate_after_websocket_network_failure(error_code)
305
+ # A send exception does not prove whether the peer received the complete
306
+ # frame. The typed error lets every caller fail closed instead of replaying.
307
+ raise UpstreamWebSocketTransportError(
308
+ codex_transport_error_message("websocket send", endpoint_id, exc),
309
+ error_code=error_code,
310
+ ) from None
311
+
312
+
313
+ class UpstreamWebSocket(Protocol):
314
+ async def send_text(self, text: str) -> None: ...
315
+
316
+ async def send_bytes(self, data: bytes) -> None: ...
317
+
318
+ async def receive(self) -> UpstreamWebSocketMessage: ...
319
+
320
+ async def close(self, code: int = 1000, reason: str = "") -> None: ...
321
+
322
+ def response_header(self, name: str) -> str | None: ...
323
+
324
+
325
+ class WebsocketsUpstreamWebSocket:
326
+ def __init__(
327
+ self,
328
+ connection: ClientConnection,
329
+ *,
330
+ uses_proxy: bool = False,
331
+ preserve_close_semantics: bool = False,
332
+ ) -> None:
333
+ self._connection = connection
334
+ self._uses_proxy = uses_proxy
335
+ self._preserve_close_semantics = preserve_close_semantics
336
+ connection_lost_waiter = getattr(connection, "connection_lost_waiter", None)
337
+ if isinstance(connection_lost_waiter, asyncio.Future):
338
+ connection_lost_waiter.add_done_callback(_consume_connection_lost_exception)
339
+
340
+ async def send_text(self, text: str) -> None:
341
+ try:
342
+ await self._connection.send(text)
343
+ except Exception as exc:
344
+ await _raise_websocket_send_error(exc, uses_proxy=self._uses_proxy)
345
+
346
+ async def send_bytes(self, data: bytes) -> None:
347
+ try:
348
+ await self._connection.send(data)
349
+ except Exception as exc:
350
+ await _raise_websocket_send_error(exc, uses_proxy=self._uses_proxy)
351
+
352
+ async def receive(self) -> UpstreamWebSocketMessage:
353
+ try:
354
+ message = await self._connection.recv()
355
+ except ConnectionClosedOK as exc:
356
+ return UpstreamWebSocketMessage(
357
+ kind="close",
358
+ close_code=_close_code_from_exception(exc),
359
+ close_reason=_close_reason_from_exception(exc),
360
+ )
361
+ except ConnectionClosedError as exc:
362
+ if self._preserve_close_semantics and exc.rcvd is not None:
363
+ return UpstreamWebSocketMessage(
364
+ kind="close",
365
+ close_code=_close_code_from_exception(exc),
366
+ close_reason=_close_reason_from_exception(exc),
367
+ )
368
+ error_code = _websocket_transport_error_code(exc, uses_proxy=self._uses_proxy)
369
+ await _rotate_after_websocket_network_failure(error_code)
370
+ relay_error_code = _relay_receive_error_code(error_code)
371
+ if relay_error_code is None and _is_keepalive_timeout_close(exc):
372
+ # Prefer the stable, provenance-checked watchdog code above.
373
+ # This text fallback preserves compatibility with keepalive
374
+ # failures whose exception shape lacks the local-send marker.
375
+ relay_error_code = "upstream_keepalive_timeout"
376
+ # ConnectionClosedError describes an incomplete close handshake,
377
+ # not generic transport provenance. Let Responses relay owners map
378
+ # it to stream_incomplete while live relays preserve received closes.
379
+ return UpstreamWebSocketMessage(
380
+ kind="error",
381
+ close_code=_close_code_from_exception(exc),
382
+ error=(
383
+ "Upstream websocket closed without a complete handshake"
384
+ if self._preserve_close_semantics
385
+ else str(exc)
386
+ ),
387
+ error_code=relay_error_code,
388
+ )
389
+ except Exception as exc:
390
+ error_code = _websocket_transport_error_code(exc, uses_proxy=self._uses_proxy)
391
+ await _rotate_after_websocket_network_failure(error_code)
392
+ return UpstreamWebSocketMessage(
393
+ kind="error",
394
+ error=codex_transport_error_message("websocket receive", None, exc),
395
+ error_code=_relay_receive_error_code(error_code),
396
+ )
397
+
398
+ if isinstance(message, str):
399
+ return UpstreamWebSocketMessage(kind="text", text=message)
400
+ if isinstance(message, bytes):
401
+ return UpstreamWebSocketMessage(kind="binary", data=message)
402
+ return UpstreamWebSocketMessage(kind="error", error=f"Unexpected websocket message type: {type(message)!r}")
403
+
404
+ async def close(self, code: int = 1000, reason: str = "") -> None:
405
+ await self._connection.close(code=code, reason=reason)
406
+
407
+ def response_header(self, name: str) -> str | None:
408
+ if name.lower() == "sec-websocket-protocol":
409
+ selected = getattr(self._connection, "subprotocol", None)
410
+ if selected is not None:
411
+ return cast(str, selected)
412
+ response = getattr(self._connection, "response", None)
413
+ headers = getattr(response, "headers", None)
414
+ if headers is None:
415
+ return None
416
+ value = headers.get(name)
417
+ if value is None:
418
+ return None
419
+ return str(value)
420
+
421
+
422
+ class NativeUpstreamWebSocket:
423
+ """Expose a native-helper WebSocket through the existing relay protocol."""
424
+
425
+ def __init__(self, websocket: NativeEgressWebSocket) -> None:
426
+ self._websocket = websocket
427
+
428
+ async def send_text(self, text: str) -> None:
429
+ try:
430
+ await self._websocket.send_text(text)
431
+ except NativeEgressError as exc:
432
+ raise _native_websocket_transport_error(exc, operation="send") from None
433
+
434
+ async def send_bytes(self, data: bytes) -> None:
435
+ try:
436
+ await self._websocket.send_bytes(data)
437
+ except NativeEgressError as exc:
438
+ raise _native_websocket_transport_error(exc, operation="send") from None
439
+
440
+ async def receive(self) -> UpstreamWebSocketMessage:
441
+ try:
442
+ message = await self._websocket.receive()
443
+ except NativeEgressError as exc:
444
+ error = _native_websocket_transport_error(exc, operation="receive")
445
+ return UpstreamWebSocketMessage(
446
+ kind="error",
447
+ error=str(error),
448
+ error_code=_relay_receive_error_code(error.error_code),
449
+ )
450
+ return UpstreamWebSocketMessage(
451
+ kind=message.kind,
452
+ text=message.text,
453
+ data=message.data,
454
+ close_code=message.close_code,
455
+ close_reason=message.close_reason,
456
+ responses_interpreted=message.responses_interpreted,
457
+ event_type=message.event_type,
458
+ payload=message.payload,
459
+ routing=message.routing,
460
+ )
461
+
462
+ async def close(self, code: int = 1000, reason: str = "") -> None:
463
+ await self._websocket.close(code=code, reason=reason)
464
+
465
+ def response_header(self, name: str) -> str | None:
466
+ return self._websocket.response_header(name)
467
+
468
+
469
+ def _native_websocket_transport_error(
470
+ exc: NativeEgressError,
471
+ *,
472
+ operation: str,
473
+ ) -> UpstreamWebSocketTransportError:
474
+ phase = exc.failure_phase if isinstance(exc, NativeEgressTransportError) else "protocol"
475
+ if phase == "liveness_timeout":
476
+ return UpstreamWebSocketTransportError(
477
+ f"Upstream websocket {operation} failed",
478
+ error_code=UPSTREAM_WEBSOCKET_LIVENESS_TIMEOUT_CODE,
479
+ )
480
+ account_neutral = phase in {"helper_exit", "helper_read", "helper_write", "shutdown"}
481
+ return UpstreamWebSocketTransportError(
482
+ f"Upstream websocket {operation} failed",
483
+ error_code=PROCESS_NETWORK_UNAVAILABLE_CODE if account_neutral else "upstream_unavailable",
484
+ )
485
+
486
+
487
+ class CodexUpstreamWebSocket:
488
+ def __init__(
489
+ self,
490
+ websocket: Any,
491
+ *,
492
+ context: Any | None = None,
493
+ codex_client: CodexClient | None = None,
494
+ owns_codex_client: bool = False,
495
+ endpoint_id: str | None = None,
496
+ response_headers: Mapping[str, str] | None = None,
497
+ ) -> None:
498
+ self._websocket = websocket
499
+ self._context = context
500
+ self._codex_client = codex_client
501
+ self._owns_codex_client = owns_codex_client
502
+ self._endpoint_id = endpoint_id
503
+ self._response_headers = _normalize_response_headers(response_headers)
504
+
505
+ async def send_text(self, text: str) -> None:
506
+ try:
507
+ result = self._websocket.send_str(text)
508
+ if asyncio.iscoroutine(result):
509
+ await result
510
+ except Exception as exc:
511
+ classification_exc = _aiohttp_stored_liveness_exception(self._websocket) or exc
512
+ await _raise_websocket_send_error(classification_exc, endpoint_id=self._endpoint_id, uses_proxy=True)
513
+
514
+ async def send_bytes(self, data: bytes) -> None:
515
+ try:
516
+ result = self._websocket.send_bytes(data)
517
+ if asyncio.iscoroutine(result):
518
+ await result
519
+ except Exception as exc:
520
+ classification_exc = _aiohttp_stored_liveness_exception(self._websocket) or exc
521
+ await _raise_websocket_send_error(classification_exc, endpoint_id=self._endpoint_id, uses_proxy=True)
522
+
523
+ async def receive(self) -> UpstreamWebSocketMessage:
524
+ try:
525
+ msg = await self._websocket.receive()
526
+ except Exception as exc:
527
+ classification_exc = _aiohttp_stored_liveness_exception(self._websocket) or exc
528
+ error_code = _websocket_transport_error_code(classification_exc, uses_proxy=True)
529
+ await _rotate_after_websocket_network_failure(error_code)
530
+ return UpstreamWebSocketMessage(
531
+ kind="error",
532
+ error=codex_transport_error_message("websocket receive", self._endpoint_id, classification_exc),
533
+ error_code=_relay_receive_error_code(error_code),
534
+ )
535
+ if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
536
+ liveness_exception = _aiohttp_stored_liveness_exception(self._websocket)
537
+ if liveness_exception is not None:
538
+ return UpstreamWebSocketMessage(
539
+ kind="error",
540
+ close_code=_aiohttp_ws_close_code(self._websocket, msg),
541
+ error=codex_transport_error_message(
542
+ "websocket receive",
543
+ self._endpoint_id,
544
+ liveness_exception,
545
+ ),
546
+ error_code=UPSTREAM_WEBSOCKET_LIVENESS_TIMEOUT_CODE,
547
+ )
548
+ return UpstreamWebSocketMessage(
549
+ kind="close",
550
+ close_code=_aiohttp_ws_close_code(self._websocket, msg),
551
+ close_reason=_aiohttp_ws_close_reason(msg),
552
+ )
553
+ if msg.type == aiohttp.WSMsgType.ERROR:
554
+ exception = (
555
+ msg.data if isinstance(msg.data, Exception) else _aiohttp_stored_liveness_exception(self._websocket)
556
+ )
557
+ error_code = (
558
+ _websocket_transport_error_code(exception, uses_proxy=True)
559
+ if exception is not None
560
+ else "upstream_unavailable"
561
+ )
562
+ await _rotate_after_websocket_network_failure(error_code)
563
+ return UpstreamWebSocketMessage(
564
+ kind="error",
565
+ error=(
566
+ codex_transport_error_message("websocket receive", self._endpoint_id, exception)
567
+ if exception is not None
568
+ else "Upstream websocket error"
569
+ ),
570
+ error_code=_relay_receive_error_code(error_code),
571
+ )
572
+ if msg.type == aiohttp.WSMsgType.TEXT:
573
+ text = msg.data if isinstance(msg.data, str) else str(msg.data)
574
+ return UpstreamWebSocketMessage(kind="text", text=text)
575
+ if msg.type == aiohttp.WSMsgType.BINARY:
576
+ return UpstreamWebSocketMessage(kind="binary", data=bytes(msg.data) if isinstance(msg.data, bytes) else b"")
577
+ return UpstreamWebSocketMessage(kind="error", error=f"Unexpected ws type: {msg.type!r}")
578
+
579
+ async def close(self, code: int = 1000, reason: str = "") -> None:
580
+ try:
581
+ result = self._websocket.close(code=code, message=reason.encode("utf-8"))
582
+ if asyncio.iscoroutine(result):
583
+ await result
584
+ finally:
585
+ try:
586
+ if self._context is not None:
587
+ await self._context.__aexit__(None, None, None)
588
+ finally:
589
+ # Context and client ownership are independent: a failed
590
+ # websocket exit must not leak the session this wrapper owns.
591
+ if self._owns_codex_client and self._codex_client is not None:
592
+ await self._codex_client.close()
593
+
594
+ def response_header(self, name: str) -> str | None:
595
+ if name.lower() == "sec-websocket-protocol":
596
+ selected = getattr(self._websocket, "protocol", None)
597
+ if selected is not None:
598
+ return cast(str, selected)
599
+ return self._response_headers.get(name.lower())
600
+
601
+
602
+ class ArchivingUpstreamWebSocket:
603
+ def __init__(
604
+ self,
605
+ wrapped: UpstreamWebSocket,
606
+ *,
607
+ url: str,
608
+ headers: dict[str, str],
609
+ account_id: str | None,
610
+ route: ResolvedUpstreamRoute | None = None,
611
+ fallback_used: bool | None = None,
612
+ direct_egress: bool = False,
613
+ archive_payloads: bool = True,
614
+ ) -> None:
615
+ self._wrapped = wrapped
616
+ self._url = url
617
+ self._headers = headers
618
+ self._account_id = account_id
619
+ self._archive_payloads = archive_payloads
620
+ self.upstream_proxy_route_mode = route.mode if route is not None else ("direct" if direct_egress else None)
621
+ self.upstream_proxy_pool_id = route.pool_id if route is not None else None
622
+ self.upstream_proxy_endpoint_id = route.endpoint_id if route is not None else None
623
+ self.upstream_proxy_fallback_used = fallback_used if route is not None else None
624
+
625
+ async def send_text(self, text: str) -> None:
626
+ if self._archive_payloads:
627
+ archive_text(
628
+ direction="codex_to_server",
629
+ kind="responses",
630
+ transport="websocket",
631
+ text=text,
632
+ account_id=self._account_id,
633
+ method="GET",
634
+ url=self._url,
635
+ headers=self._headers,
636
+ extra={"frame_type": "text"},
637
+ )
638
+ await self._wrapped.send_text(text)
639
+
640
+ async def send_bytes(self, data: bytes) -> None:
641
+ if self._archive_payloads:
642
+ archive_bytes(
643
+ direction="codex_to_server",
644
+ kind="responses",
645
+ transport="websocket",
646
+ data=data,
647
+ account_id=self._account_id,
648
+ method="GET",
649
+ url=self._url,
650
+ headers=self._headers,
651
+ extra={"frame_type": "binary"},
652
+ )
653
+ await self._wrapped.send_bytes(data)
654
+
655
+ async def receive(self) -> UpstreamWebSocketMessage:
656
+ message = await self._wrapped.receive()
657
+ return message
658
+
659
+ def archive_received(self, message: UpstreamWebSocketMessage) -> None:
660
+ if not self._archive_payloads:
661
+ return
662
+ if message.kind == "text" and message.text is not None:
663
+ archive_text(
664
+ direction="server_to_codex",
665
+ kind="responses",
666
+ transport="websocket",
667
+ text=message.text,
668
+ account_id=self._account_id,
669
+ method="GET",
670
+ url=self._url,
671
+ headers=self._headers,
672
+ extra={"frame_type": "text"},
673
+ )
674
+ elif message.kind == "binary" and message.data is not None:
675
+ archive_bytes(
676
+ direction="server_to_codex",
677
+ kind="responses",
678
+ transport="websocket",
679
+ data=message.data,
680
+ account_id=self._account_id,
681
+ method="GET",
682
+ url=self._url,
683
+ headers=self._headers,
684
+ extra={"frame_type": "binary"},
685
+ )
686
+ else:
687
+ archive_text(
688
+ direction="server_to_codex",
689
+ kind="responses",
690
+ transport="websocket",
691
+ text=message.error or "",
692
+ account_id=self._account_id,
693
+ method="GET",
694
+ url=self._url,
695
+ headers=self._headers,
696
+ extra={"frame_type": message.kind, "close_code": message.close_code},
697
+ )
698
+
699
+ async def close(self, code: int = 1000, reason: str = "") -> None:
700
+ await self._wrapped.close(code=code, reason=reason)
701
+
702
+ def response_header(self, name: str) -> str | None:
703
+ return self._wrapped.response_header(name)
704
+
705
+
706
+ def _connection_header_tokens(headers: Mapping[str, str]) -> set[str]:
707
+ tokens: set[str] = set()
708
+ for key, value in headers.items():
709
+ if key.lower() != "connection":
710
+ continue
711
+ tokens.update(token.strip().lower() for token in value.split(",") if token.strip())
712
+ return tokens
713
+
714
+
715
+ def filter_inbound_websocket_headers(headers: Mapping[str, str]) -> dict[str, str]:
716
+ filtered = filter_inbound_headers(headers)
717
+ blocked_header_names = _WEBSOCKET_HOP_BY_HOP_HEADERS | _connection_header_tokens(filtered)
718
+ return {key: value for key, value in filtered.items() if key.lower() not in blocked_header_names}
719
+
720
+
721
+ def _build_upstream_websocket_headers(
722
+ inbound: dict[str, str],
723
+ access_token: str,
724
+ account_id: str | None,
725
+ *,
726
+ include_responses_beta: bool = True,
727
+ normalize_non_native_fingerprint: bool = True,
728
+ routing_hint: tuple[str, str | None] | None = None,
729
+ ) -> dict[str, str]:
730
+ headers = filter_inbound_websocket_headers(inbound)
731
+ # ``filter_inbound_websocket_headers`` strips ``x-codex-installation-id`` because it
732
+ # lives in ``IGNORE_INBOUND_HEADERS``. Callers normalize the selected account's
733
+ # canonical installation id onto the inbound headers before connecting (mirroring the
734
+ # HTTP ``/codex/responses`` egress, where ``apply_codex_installation_headers`` runs as
735
+ # the final post-filter step). Re-add it here so the websocket handshake keeps header
736
+ # parity instead of losing the standalone installation header to this second filter.
737
+ installation_id = next(
738
+ (value for key, value in inbound.items() if key.lower() == CODEX_INSTALLATION_ID_HEADER),
739
+ None,
740
+ )
741
+ if installation_id:
742
+ headers[CODEX_INSTALLATION_ID_HEADER] = installation_id
743
+ native = _is_native_codex_request(headers)
744
+ lower_keys = {key.lower() for key in headers}
745
+ if "x-request-id" not in lower_keys and "request-id" not in lower_keys:
746
+ request_id = get_request_id()
747
+ if request_id:
748
+ headers["x-request-id"] = request_id
749
+ # Normalize a non-native client's fingerprint on the client-facing
750
+ # ``/v1/responses`` websocket egress too. This builder is the upstream egress
751
+ # for a direct websocket caller, so without normalization an OpenAI SDK that
752
+ # speaks the responses websocket protocol would reach upstream with its
753
+ # ``OpenAI/Python`` / ``x-openai-client-*`` / ``x-stainless-*`` fingerprint
754
+ # intact and trigger the priority downgrade this change exists to prevent.
755
+ if normalize_non_native_fingerprint and not native:
756
+ _normalize_non_native_upstream_fingerprint(headers)
757
+ headers["Authorization"] = f"Bearer {access_token}"
758
+ if account_id:
759
+ if native:
760
+ headers["chatgpt-account-id"] = account_id
761
+ else:
762
+ headers[_CHATGPT_ACCOUNT_ID_HEADER] = account_id
763
+ if include_responses_beta:
764
+ _ensure_responses_websocket_beta_header(headers)
765
+ if routing_hint is not None:
766
+ model, service_tier = routing_hint
767
+ headers[CODEX_ROUTING_HINT_HEADER] = f"model={model}"
768
+ if service_tier is not None:
769
+ headers[CODEX_ROUTING_HINT_HEADER] += f";tier={service_tier}"
770
+ return headers
771
+
772
+
773
+ def _build_upstream_live_websocket_headers(
774
+ inbound: dict[str, str],
775
+ access_token: str,
776
+ account_id: str | None,
777
+ ) -> dict[str, str]:
778
+ headers = _build_upstream_websocket_headers(
779
+ inbound,
780
+ access_token,
781
+ account_id,
782
+ include_responses_beta=False,
783
+ normalize_non_native_fingerprint=False,
784
+ )
785
+ beta_value = _pop_header_case_insensitive(headers, "openai-beta")
786
+ if beta_value:
787
+ retained_tokens: list[str] = []
788
+ for raw_token in beta_value.split(","):
789
+ token = raw_token.strip()
790
+ if not token:
791
+ continue
792
+ normalized_token = token.lower()
793
+ if (
794
+ normalized_token in _RESPONSES_WEBSOCKET_INCOMPATIBLE_BETA_HEADERS
795
+ or normalized_token.partition("=")[0].strip() == "responses_websockets"
796
+ ):
797
+ continue
798
+ retained_tokens.append(token)
799
+ if retained_tokens:
800
+ headers["openai-beta"] = ", ".join(retained_tokens)
801
+ return headers
802
+
803
+
804
+ def _live_websocket_url(
805
+ call_id: str,
806
+ *,
807
+ protocol: RealtimeWebSocketProtocol,
808
+ base_url: str = _OPENAI_LIVE_BASE_URL,
809
+ query_params: list[tuple[str, str]] | None = None,
810
+ ) -> str:
811
+ normalized = normalize_realtime_call_id(call_id)
812
+ if normalized is None:
813
+ raise ValueError("Invalid realtime call id")
814
+
815
+ parsed = urlparse(base_url.rstrip("/"))
816
+ base_path = parsed.path.rstrip("/")
817
+ configured_query_pairs = parse_qsl(parsed.query, keep_blank_values=True)
818
+ forwarded_query_pairs = list(query_params or ())
819
+ if protocol is RealtimeWebSocketProtocol.LIVE_V3:
820
+ if any(key == "call_id" for key, _value in (*configured_query_pairs, *forwarded_query_pairs)):
821
+ raise ValueError("Path-based realtime query parameters must not include call_id")
822
+ path = f"{base_path}/live/{quote(normalized, safe='')}"
823
+ query_pairs = [*configured_query_pairs, *forwarded_query_pairs]
824
+ elif protocol is RealtimeWebSocketProtocol.REALTIME_V1_V2:
825
+ if any(key == "call_id" for key, _value in (*configured_query_pairs, *forwarded_query_pairs)):
826
+ raise ValueError("Legacy realtime query parameters must not include call_id")
827
+ path = f"{base_path}/realtime"
828
+ query_pairs = [*configured_query_pairs, *forwarded_query_pairs, ("call_id", normalized)]
829
+ else:
830
+ raise ValueError("Unsupported realtime websocket protocol")
831
+
832
+ if parsed.scheme == "https":
833
+ scheme = "wss"
834
+ elif parsed.scheme == "http":
835
+ scheme = "ws"
836
+ else:
837
+ scheme = parsed.scheme
838
+ return urlunparse(
839
+ parsed._replace(
840
+ scheme=scheme,
841
+ path=path,
842
+ query=urlencode(query_pairs, doseq=True),
843
+ )
844
+ )
845
+
846
+
847
+ def _ensure_responses_websocket_beta_header(headers: dict[str, str]) -> None:
848
+ header_key = next((key for key in headers if key.lower() == "openai-beta"), "openai-beta")
849
+ current_value = headers.get(header_key, "")
850
+ beta_tokens = [
851
+ token.strip()
852
+ for token in current_value.split(",")
853
+ if token.strip() and token.strip().lower() not in _RESPONSES_WEBSOCKET_INCOMPATIBLE_BETA_HEADERS
854
+ ]
855
+ if _RESPONSES_WEBSOCKET_BETA_HEADER.lower() not in {token.lower() for token in beta_tokens}:
856
+ beta_tokens.append(_RESPONSES_WEBSOCKET_BETA_HEADER)
857
+ headers[header_key] = ", ".join(beta_tokens)
858
+
859
+
860
+ def _pop_header_case_insensitive(headers: dict[str, str], name: str) -> str | None:
861
+ lowered = name.lower()
862
+ for key in tuple(headers):
863
+ if key.lower() != lowered:
864
+ continue
865
+ return headers.pop(key)
866
+ return None
867
+
868
+
869
+ def _aiohttp_ws_close_code(websocket: Any, message: aiohttp.WSMessage) -> int | None:
870
+ if isinstance(message.data, int):
871
+ return message.data
872
+ close_code = getattr(websocket, "close_code", None)
873
+ return close_code if isinstance(close_code, int) else None
874
+
875
+
876
+ def _aiohttp_ws_close_reason(message: aiohttp.WSMessage) -> str | None:
877
+ reason = message.extra
878
+ return reason if isinstance(reason, str) and reason else None
879
+
880
+
881
+ def _responses_websocket_url(base_url: str) -> str:
882
+ parsed = urlparse(f"{base_url.rstrip('/')}/codex/responses")
883
+ if parsed.scheme == "https":
884
+ scheme = "wss"
885
+ elif parsed.scheme == "http":
886
+ scheme = "ws"
887
+ else:
888
+ scheme = parsed.scheme
889
+ return urlunparse(parsed._replace(scheme=scheme))
890
+
891
+
892
+ async def _connect_upstream_websocket(
893
+ headers: dict[str, str],
894
+ access_token: str,
895
+ account_id: str | None,
896
+ *,
897
+ url: str,
898
+ route: ResolvedUpstreamRoute | None = None,
899
+ codex_client: CodexClient | None = None,
900
+ allow_direct_egress: bool = False,
901
+ policy: _UpstreamWebSocketPolicy,
902
+ subprotocols: Sequence[str] = (),
903
+ routing_hint: tuple[str, str | None] | None = None,
904
+ ) -> UpstreamWebSocket:
905
+ settings = with_dashboard_overrides(get_settings())
906
+ if policy.include_responses_beta:
907
+ upstream_headers = _build_upstream_websocket_headers(
908
+ headers, access_token, account_id, routing_hint=routing_hint
909
+ )
910
+ else:
911
+ upstream_headers = _build_upstream_live_websocket_headers(headers, access_token, account_id)
912
+ require_route_or_direct_egress_opt_in(
913
+ route=route,
914
+ allow_direct_egress=allow_direct_egress,
915
+ operation=policy.operation,
916
+ )
917
+ if route is not None:
918
+ owns_codex_client = codex_client is None
919
+ active_codex_client = codex_client or CodexClient(create_codex_session())
920
+ endpoint_id = route.endpoint_id
921
+ active_route = route
922
+ fallback_used = False
923
+ heartbeat = settings.proxy_downstream_websocket_idle_timeout_seconds if policy.enable_routed_heartbeat else None
924
+ protocol_kwargs = {"protocols": subprotocols} if subprotocols else {}
925
+ try:
926
+ opener = getattr(active_codex_client, "open_ws_with_route_metadata", None)
927
+ if callable(opener):
928
+ result = await opener(
929
+ url,
930
+ route=route,
931
+ retry_handshake_status=policy.retry_handshake_status,
932
+ retry_network_errors=policy.retry_routed_network_errors,
933
+ headers=upstream_headers,
934
+ timeout=settings.upstream_connect_timeout_seconds,
935
+ max_msg_size=MAX_SSE_EVENT_BYTES,
936
+ heartbeat=heartbeat,
937
+ compress=15,
938
+ native_interpret_responses=policy.include_responses_beta,
939
+ **protocol_kwargs,
940
+ )
941
+ context = result.context
942
+ websocket = result.websocket
943
+ endpoint_id = result.route.endpoint_id
944
+ active_route = result.route
945
+ fallback_used = result.fallback_used
946
+ native_routed = bool(getattr(result, "native", False))
947
+ else:
948
+ context = await active_codex_client.ws_connect(
949
+ url,
950
+ route=route,
951
+ headers=upstream_headers,
952
+ timeout=settings.upstream_connect_timeout_seconds,
953
+ max_msg_size=MAX_SSE_EVENT_BYTES,
954
+ heartbeat=heartbeat,
955
+ compress=15,
956
+ **protocol_kwargs,
957
+ )
958
+ websocket = await context.__aenter__() if hasattr(context, "__aenter__") else context
959
+ if not hasattr(context, "__aenter__"):
960
+ context = None
961
+ endpoint_id = route.endpoint_id
962
+ native_routed = False
963
+ except asyncio.CancelledError:
964
+ if owns_codex_client:
965
+ try:
966
+ await active_codex_client.close()
967
+ except Exception:
968
+ logger.warning("Failed to close routed websocket client after cancelled handshake")
969
+ raise
970
+ except CodexTransportError as exc:
971
+ if owns_codex_client:
972
+ await active_codex_client.close()
973
+ error_code = exc.error_code or "upstream_unavailable"
974
+ status_code = exc.status_code if exc.status_code is not None and 400 <= exc.status_code <= 599 else 502
975
+ if policy.credential_safe_connect_errors:
976
+ message = (
977
+ f"Upstream websocket handshake failed with HTTP {status_code}"
978
+ if exc.status_code is not None
979
+ else "Upstream websocket connection failed"
980
+ )
981
+ else:
982
+ message = str(exc)
983
+ raise ProxyResponseError(
984
+ status_code if policy.preserve_handshake_status else 502,
985
+ openai_error(error_code, message, error_type="server_error"),
986
+ failure_phase="connect",
987
+ # Carry the client's dispatch provenance across the sanitizing
988
+ # boundary: a typed connector failure against the routed proxy
989
+ # proves no ``response.create`` frame could have reached
990
+ # upstream, so service-level failover may replay the request
991
+ # on another account. TLS verification failures are stable
992
+ # endpoint configuration errors and stay non-replayable.
993
+ retryable_same_contract=(
994
+ (policy.retry_routed_network_errors and error_code == PROCESS_NETWORK_UNAVAILABLE_CODE)
995
+ or (exc.retryable_same_contract and not exc.is_tls_verification_failure)
996
+ ),
997
+ failure_detail=(
998
+ "proxy_connect_pre_dispatch"
999
+ if exc.retryable_same_contract and not exc.is_tls_verification_failure
1000
+ else "transport_error"
1001
+ ),
1002
+ failure_exception_type=type(exc).__name__,
1003
+ ) from exc
1004
+ except Exception:
1005
+ if owns_codex_client:
1006
+ await active_codex_client.close()
1007
+ raise
1008
+ if native_routed:
1009
+ if owns_codex_client:
1010
+ try:
1011
+ await active_codex_client.close()
1012
+ except Exception:
1013
+ logger.warning(
1014
+ "Failed to close owned Codex client after native websocket connect",
1015
+ exc_info=True,
1016
+ )
1017
+ return ArchivingUpstreamWebSocket(
1018
+ NativeUpstreamWebSocket(cast(NativeEgressWebSocket, websocket)),
1019
+ url=url,
1020
+ headers=upstream_headers,
1021
+ account_id=account_id,
1022
+ route=active_route,
1023
+ fallback_used=fallback_used,
1024
+ archive_payloads=policy.archive_payloads,
1025
+ )
1026
+ return ArchivingUpstreamWebSocket(
1027
+ CodexUpstreamWebSocket(
1028
+ websocket,
1029
+ context=context if hasattr(context, "__aenter__") else None,
1030
+ codex_client=active_codex_client,
1031
+ owns_codex_client=owns_codex_client,
1032
+ endpoint_id=endpoint_id,
1033
+ response_headers=_codex_websocket_response_headers(websocket, context),
1034
+ ),
1035
+ url=url,
1036
+ headers=upstream_headers,
1037
+ account_id=account_id,
1038
+ route=active_route,
1039
+ fallback_used=fallback_used,
1040
+ archive_payloads=policy.archive_payloads,
1041
+ )
1042
+ # Ping/pong control frames verify transport liveness without treating valid
1043
+ # application-frame silence as an idle response.
1044
+ ping_timeout = (
1045
+ settings.proxy_downstream_websocket_idle_timeout_seconds if policy.enable_direct_ping_timeout else None
1046
+ )
1047
+ native_client = discover_native_egress_client()
1048
+ if native_client is not None:
1049
+ native_headers = dict(upstream_headers)
1050
+ if subprotocols:
1051
+ native_headers["sec-websocket-protocol"] = ", ".join(subprotocols)
1052
+ proxy_env = (
1053
+ settings.upstream_websocket_proxy_env() if hasattr(settings, "upstream_websocket_proxy_env") else os.environ
1054
+ )
1055
+ proxy_url = resolve_websocket_proxy_from_env(url, proxy_env) if settings.upstream_websocket_trust_env else None
1056
+ try:
1057
+ native_websocket = await native_client.websocket(
1058
+ NativeWebSocketRequest(
1059
+ url=url,
1060
+ headers=native_headers,
1061
+ connect_timeout_seconds=settings.upstream_connect_timeout_seconds,
1062
+ max_message_bytes=MAX_SSE_EVENT_BYTES,
1063
+ ping_interval_seconds=20.0,
1064
+ ping_timeout_seconds=ping_timeout,
1065
+ proxy_url=proxy_url,
1066
+ interpret_responses=policy.include_responses_beta,
1067
+ )
1068
+ )
1069
+ except NativeEgressUnavailable:
1070
+ # Startup failed before the handshake command could be dispatched;
1071
+ # retain zero-configuration behavior for non-container installs.
1072
+ pass
1073
+ except NativeEgressTransportError as exc:
1074
+ raise _native_websocket_handshake_error(exc, policy=policy) from exc
1075
+ except NativeEgressError as exc:
1076
+ raise ProxyResponseError(
1077
+ 502,
1078
+ openai_error(
1079
+ "upstream_unavailable",
1080
+ "Invalid upstream websocket handshake",
1081
+ error_type="server_error",
1082
+ ),
1083
+ failure_phase="connect",
1084
+ ) from exc
1085
+ else:
1086
+ return ArchivingUpstreamWebSocket(
1087
+ NativeUpstreamWebSocket(native_websocket),
1088
+ url=url,
1089
+ headers=native_headers,
1090
+ account_id=account_id,
1091
+ direct_egress=allow_direct_egress,
1092
+ archive_payloads=policy.archive_payloads,
1093
+ )
1094
+ origin = cast(Origin | None, _pop_header_case_insensitive(upstream_headers, "origin"))
1095
+ user_agent = _pop_header_case_insensitive(upstream_headers, "user-agent")
1096
+ proxy_env = (
1097
+ settings.upstream_websocket_proxy_env() if hasattr(settings, "upstream_websocket_proxy_env") else os.environ
1098
+ )
1099
+ proxy_url = resolve_websocket_proxy_from_env(url, proxy_env) if settings.upstream_websocket_trust_env else None
1100
+ try:
1101
+ subprotocol_kwargs = {"subprotocols": cast(Sequence[Subprotocol], subprotocols)} if subprotocols else {}
1102
+ ssl_kwargs = {"ssl": _shared_system_ssl_context()} if urlparse(url).scheme == "wss" else {}
1103
+ response = await websocket_connect(
1104
+ url,
1105
+ origin=origin,
1106
+ additional_headers=upstream_headers or None,
1107
+ user_agent_header=user_agent,
1108
+ open_timeout=settings.upstream_connect_timeout_seconds,
1109
+ ping_timeout=ping_timeout,
1110
+ max_size=MAX_SSE_EVENT_BYTES,
1111
+ proxy=proxy_url,
1112
+ # Codex offers permessage-deflate on its upstream handshake. Keep
1113
+ # the direct path's default offer aligned with the routed aiohttp
1114
+ # path (``compress=15`` above).
1115
+ compression="deflate",
1116
+ **subprotocol_kwargs,
1117
+ **ssl_kwargs,
1118
+ )
1119
+ except asyncio.TimeoutError as exc:
1120
+ raise ProxyResponseError(
1121
+ 502,
1122
+ openai_error("upstream_unavailable", "Request to upstream timed out"),
1123
+ failure_phase="connect",
1124
+ failure_detail=UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL,
1125
+ ) from exc
1126
+ except InvalidStatus as exc:
1127
+ response = exc.response
1128
+ is_edge_challenge = _is_upstream_edge_challenge(
1129
+ response.status_code,
1130
+ headers=response.headers,
1131
+ body=response.body,
1132
+ )
1133
+ if policy.credential_safe_connect_errors:
1134
+ status_code = response.status_code if 400 <= response.status_code <= 599 else 502
1135
+ payload = openai_error(
1136
+ "upstream_websocket_handshake_failed",
1137
+ f"Upstream websocket handshake failed with HTTP {status_code}",
1138
+ error_type="server_error",
1139
+ )
1140
+ else:
1141
+ status_code = response.status_code
1142
+ message = response.reason_phrase or f"Upstream websocket error: HTTP {status_code}"
1143
+ payload = _handshake_error_payload(status_code, message, response.headers, response.body)
1144
+ raise ProxyResponseError(
1145
+ status_code,
1146
+ payload,
1147
+ failure_phase="connect",
1148
+ # A 5xx upgrade rejection is the edge refusing to carry websockets
1149
+ # at all. The sanitized code cannot carry that provenance: this
1150
+ # policy preserves the upstream body, so the same outage surfaces
1151
+ # as ``upstream_error`` or whatever code the edge returned.
1152
+ # Credential-scoped rejections (401/403/429) stay account evidence,
1153
+ # with one narrow exception: a 403 carrying explicit Cloudflare
1154
+ # edge-challenge evidence is the edge refusing the websocket
1155
+ # upgrade itself, not a verdict on the account, so it shares the
1156
+ # transport-failure provenance and steers clients to HTTP.
1157
+ failure_detail=(
1158
+ UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL if status_code >= 500 or is_edge_challenge else None
1159
+ ),
1160
+ ) from exc
1161
+ except InvalidProxy as exc:
1162
+ # str(exc) embeds the full proxy URL including userinfo; the URL is
1163
+ # operator configuration, never actionable for the API client. The
1164
+ # websockets reason (``exc.msg``) never contains the URL.
1165
+ logger.warning("%s rejected upstream websocket proxy configuration: %s", policy.operation, exc.msg)
1166
+ raise ProxyResponseError(
1167
+ 502,
1168
+ openai_error(
1169
+ "upstream_unavailable",
1170
+ "Invalid upstream websocket proxy configuration",
1171
+ error_type="server_error",
1172
+ ),
1173
+ ) from exc
1174
+ except InvalidHandshake as exc:
1175
+ message = (
1176
+ "Invalid upstream websocket handshake"
1177
+ if policy.credential_safe_connect_errors
1178
+ else (str(exc) or "Invalid upstream websocket handshake")
1179
+ )
1180
+ raise ProxyResponseError(
1181
+ 502,
1182
+ openai_error("upstream_unavailable", message),
1183
+ failure_phase="connect",
1184
+ failure_detail=UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL,
1185
+ ) from exc
1186
+ except OSError as exc:
1187
+ error_code = process_network_error_code(
1188
+ exc,
1189
+ fallback="upstream_unavailable",
1190
+ include_permanent_dns=proxy_url is None,
1191
+ )
1192
+ message = "Upstream websocket connection failed" if policy.credential_safe_connect_errors else str(exc)
1193
+ raise ProxyResponseError(
1194
+ 502,
1195
+ openai_error(error_code, message),
1196
+ failure_phase="connect",
1197
+ retryable_same_contract=error_code == PROCESS_NETWORK_UNAVAILABLE_CODE,
1198
+ # Host-wide network loss keeps its account-neutral process
1199
+ # recovery path, and a TLS verification failure is stable endpoint
1200
+ # configuration; neither is an upstream websocket outage that raw
1201
+ # HTTP can route around.
1202
+ failure_detail=(
1203
+ None
1204
+ if error_code == PROCESS_NETWORK_UNAVAILABLE_CODE or isinstance(exc, ssl.SSLError)
1205
+ else UPSTREAM_WEBSOCKET_TRANSPORT_FAILURE_DETAIL
1206
+ ),
1207
+ ) from exc
1208
+
1209
+ return ArchivingUpstreamWebSocket(
1210
+ WebsocketsUpstreamWebSocket(
1211
+ response,
1212
+ uses_proxy=proxy_url is not None,
1213
+ preserve_close_semantics=policy.preserve_close_semantics,
1214
+ ),
1215
+ url=url,
1216
+ headers=upstream_headers,
1217
+ account_id=account_id,
1218
+ direct_egress=allow_direct_egress,
1219
+ archive_payloads=policy.archive_payloads,
1220
+ )
1221
+
1222
+
1223
+ def _native_websocket_handshake_error(
1224
+ exc: NativeEgressTransportError,
1225
+ *,
1226
+ policy: _UpstreamWebSocketPolicy,
1227
+ ) -> ProxyResponseError:
1228
+ status_code = exc.status_code if exc.status_code is not None and 400 <= exc.status_code <= 599 else 502
1229
+ if policy.credential_safe_connect_errors:
1230
+ message = (
1231
+ f"Upstream websocket handshake failed with HTTP {status_code}"
1232
+ if exc.status_code is not None
1233
+ else "Upstream websocket connection failed"
1234
+ )
1235
+ payload = openai_error(
1236
+ "upstream_websocket_handshake_failed" if exc.status_code is not None else "upstream_unavailable",
1237
+ message,
1238
+ error_type="server_error",
1239
+ )
1240
+ elif exc.status_code is not None:
1241
+ headers = Headers(exc.headers)
1242
+ payload = _handshake_error_payload(
1243
+ status_code,
1244
+ f"Upstream websocket error: HTTP {status_code}",
1245
+ headers,
1246
+ exc.body,
1247
+ )
1248
+ else:
1249
+ message = (
1250
+ "Request to upstream timed out"
1251
+ if exc.failure_phase == "timeout"
1252
+ else "Upstream websocket connection failed"
1253
+ )
1254
+ payload = openai_error("upstream_unavailable", message, error_type="server_error")
1255
+ return ProxyResponseError(
1256
+ status_code if policy.preserve_handshake_status else 502,
1257
+ payload,
1258
+ failure_phase="connect",
1259
+ retryable_same_contract=exc.retryable_same_contract,
1260
+ failure_detail="native_websocket_pre_dispatch" if exc.retryable_same_contract else "transport_error",
1261
+ failure_exception_type=type(exc).__name__,
1262
+ )
1263
+
1264
+
1265
+ async def connect_responses_websocket(
1266
+ headers: dict[str, str],
1267
+ access_token: str,
1268
+ account_id: str | None,
1269
+ *,
1270
+ base_url: str | None = None,
1271
+ route: ResolvedUpstreamRoute | None = None,
1272
+ codex_client: CodexClient | None = None,
1273
+ allow_direct_egress: bool = False,
1274
+ routing_hint: tuple[str, str | None] | None = None,
1275
+ ) -> UpstreamWebSocket:
1276
+ settings = get_settings()
1277
+ upstream_base = (base_url or settings.upstream_base_url).rstrip("/")
1278
+ return await _connect_upstream_websocket(
1279
+ headers,
1280
+ access_token,
1281
+ account_id,
1282
+ url=_responses_websocket_url(upstream_base),
1283
+ route=route,
1284
+ codex_client=codex_client,
1285
+ allow_direct_egress=allow_direct_egress,
1286
+ policy=_RESPONSES_WEBSOCKET_POLICY,
1287
+ routing_hint=routing_hint,
1288
+ )
1289
+
1290
+
1291
+ async def connect_live_websocket(
1292
+ call_id: str,
1293
+ headers: dict[str, str],
1294
+ access_token: str,
1295
+ account_id: str | None,
1296
+ *,
1297
+ protocol: RealtimeWebSocketProtocol,
1298
+ route: ResolvedUpstreamRoute | None = None,
1299
+ codex_client: CodexClient | None = None,
1300
+ allow_direct_egress: bool = False,
1301
+ base_url: str = _OPENAI_LIVE_BASE_URL,
1302
+ query_params: list[tuple[str, str]] | None = None,
1303
+ subprotocols: Sequence[str] = (),
1304
+ ) -> UpstreamWebSocket:
1305
+ """Connect an account-bound Codex realtime sideband without refreshing auth."""
1306
+
1307
+ return await _connect_upstream_websocket(
1308
+ headers,
1309
+ access_token,
1310
+ account_id,
1311
+ url=_live_websocket_url(
1312
+ call_id,
1313
+ protocol=protocol,
1314
+ base_url=base_url,
1315
+ query_params=query_params,
1316
+ ),
1317
+ route=route,
1318
+ codex_client=codex_client,
1319
+ allow_direct_egress=allow_direct_egress,
1320
+ policy=_LIVE_SIDEBAND_WEBSOCKET_POLICY,
1321
+ subprotocols=subprotocols,
1322
+ )
1323
+
1324
+
1325
+ def _close_code_from_exception(exc: ConnectionClosedOK | ConnectionClosedError) -> int | None:
1326
+ if exc.rcvd is not None:
1327
+ return int(exc.rcvd.code)
1328
+ if exc.sent is not None:
1329
+ return int(exc.sent.code)
1330
+ return None
1331
+
1332
+
1333
+ def _close_reason_from_exception(exc: ConnectionClosedOK | ConnectionClosedError) -> str | None:
1334
+ frame = exc.rcvd if exc.rcvd is not None else exc.sent
1335
+ if frame is None:
1336
+ return None
1337
+ return frame.reason or None
1338
+
1339
+
1340
+ def _codex_websocket_response_headers(websocket: object, context: object | None) -> Mapping[str, str]:
1341
+ for source in (websocket, context):
1342
+ headers = _response_headers_from_source(source)
1343
+ if headers:
1344
+ return headers
1345
+ return {}
1346
+
1347
+
1348
+ def _response_headers_from_source(source: object | None) -> Mapping[str, str]:
1349
+ if source is None:
1350
+ return {}
1351
+ for attr in ("response", "handshake_response"):
1352
+ response = getattr(source, attr, None)
1353
+ headers = getattr(response, "headers", None)
1354
+ if headers:
1355
+ return _normalize_response_headers(headers)
1356
+ for attr in ("headers", "response_headers"):
1357
+ headers = getattr(source, attr, None)
1358
+ if headers:
1359
+ return _normalize_response_headers(headers)
1360
+ return {}
1361
+
1362
+
1363
+ def _normalize_response_headers(headers: Mapping[str, object] | None) -> dict[str, str]:
1364
+ if headers is None:
1365
+ return {}
1366
+ return {str(key).lower(): str(value) for key, value in headers.items()}
1367
+
1368
+
1369
+ def _handshake_error_payload(
1370
+ status_code: int,
1371
+ message: str,
1372
+ headers: Headers | None = None,
1373
+ body: bytes | bytearray | None = None,
1374
+ ) -> OpenAIErrorEnvelope:
1375
+ parsed = _try_parse_handshake_error_payload(headers, body)
1376
+ if parsed is not None:
1377
+ return parsed
1378
+ if status_code == 401:
1379
+ return openai_error("invalid_api_key", message, error_type="authentication_error")
1380
+ if status_code == 429:
1381
+ return openai_error("rate_limit_exceeded", message, error_type="rate_limit_error")
1382
+ if status_code == 403:
1383
+ return openai_error("forbidden", message, error_type="permission_error")
1384
+ if status_code >= 500:
1385
+ return openai_error("upstream_error", message, error_type="server_error")
1386
+ return openai_error("invalid_request_error", message, error_type="invalid_request_error")
1387
+
1388
+
1389
+ def _try_parse_handshake_error_payload(
1390
+ headers: Headers | None,
1391
+ body: bytes | bytearray | None,
1392
+ ) -> OpenAIErrorEnvelope | None:
1393
+ if not body:
1394
+ return None
1395
+
1396
+ content_type = ""
1397
+ if headers is not None:
1398
+ content_type = headers.get("Content-Type", "")
1399
+
1400
+ if "json" not in content_type.lower() and not body.strip().startswith((b"{", b"[")):
1401
+ return None
1402
+
1403
+ try:
1404
+ payload = json.loads(body.decode("utf-8", errors="replace"))
1405
+ except json.JSONDecodeError:
1406
+ return None
1407
+
1408
+ error = parse_error_payload(payload)
1409
+ if error is None:
1410
+ return None
1411
+ return {"error": _openai_error_detail(error)}