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.
- app/__init__.py +10 -0
- app/admin_cli.py +494 -0
- app/cli.py +339 -0
- app/codex_sessions_retag.py +487 -0
- app/core/__init__.py +0 -0
- app/core/audit/__init__.py +0 -0
- app/core/audit/service.py +216 -0
- app/core/audit/types.py +94 -0
- app/core/auth/__init__.py +236 -0
- app/core/auth/api_key_cache.py +65 -0
- app/core/auth/dashboard_access.py +429 -0
- app/core/auth/dashboard_mode.py +171 -0
- app/core/auth/dashboard_session_ttl.py +74 -0
- app/core/auth/dashboard_users_cache.py +120 -0
- app/core/auth/dependencies.py +710 -0
- app/core/auth/external_identity.py +78 -0
- app/core/auth/guardian.py +374 -0
- app/core/auth/models.py +49 -0
- app/core/auth/providers/__init__.py +129 -0
- app/core/auth/providers/oidc.py +838 -0
- app/core/auth/providers/registry.py +118 -0
- app/core/auth/refresh.py +394 -0
- app/core/auth/step_up.py +117 -0
- app/core/auth/totp.py +81 -0
- app/core/balancer/__init__.py +71 -0
- app/core/balancer/logic.py +1612 -0
- app/core/balancer/rendezvous_hash.py +21 -0
- app/core/balancer/types.py +21 -0
- app/core/bootstrap.py +172 -0
- app/core/cache/invalidation.py +426 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/codex.py +996 -0
- app/core/clients/codex_version.py +187 -0
- app/core/clients/codex_version_snapshot.py +2 -0
- app/core/clients/files.py +534 -0
- app/core/clients/headers.py +29 -0
- app/core/clients/http.py +511 -0
- app/core/clients/model_fetcher.py +276 -0
- app/core/clients/native_egress.py +1304 -0
- app/core/clients/oauth.py +477 -0
- app/core/clients/proxy.py +6089 -0
- app/core/clients/proxy_websocket.py +1411 -0
- app/core/clients/rate_limit_reset_credits.py +412 -0
- app/core/clients/stream_errors.py +12 -0
- app/core/clients/thread_cache_identity.py +347 -0
- app/core/clients/usage.py +467 -0
- app/core/clock.py +170 -0
- app/core/config/__init__.py +0 -0
- app/core/config/background_jobs.py +73 -0
- app/core/config/context_window_overrides.py +166 -0
- app/core/config/dashboard_overrides.py +145 -0
- app/core/config/inheritable.py +47 -0
- app/core/config/key_fingerprint.py +115 -0
- app/core/config/settings.py +872 -0
- app/core/config/settings_cache.py +100 -0
- app/core/config/spool_retention.py +217 -0
- app/core/config/tiers.py +174 -0
- app/core/conversation_archive.py +568 -0
- app/core/crypto.py +37 -0
- app/core/draining.py +11 -0
- app/core/errors.py +365 -0
- app/core/exceptions.py +155 -0
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +478 -0
- app/core/http_protocol.py +176 -0
- app/core/http_protocol_httptools.py +204 -0
- app/core/ingress_limits.py +17 -0
- app/core/metadata_store.py +18 -0
- app/core/metrics/__init__.py +0 -0
- app/core/metrics/middleware.py +74 -0
- app/core/metrics/prometheus.py +604 -0
- app/core/middleware/__init__.py +25 -0
- app/core/middleware/api_firewall.py +111 -0
- app/core/middleware/app_version.py +37 -0
- app/core/middleware/dashboard_auth_proxy.py +55 -0
- app/core/middleware/dashboard_csrf.py +177 -0
- app/core/middleware/dashboard_gzip.py +41 -0
- app/core/middleware/dashboard_overrides.py +59 -0
- app/core/middleware/firewall_cache.py +91 -0
- app/core/middleware/inflight.py +126 -0
- app/core/middleware/multipart_content_encoding.py +129 -0
- app/core/middleware/path_rewrite.py +148 -0
- app/core/middleware/request_body_limit.py +196 -0
- app/core/middleware/request_decompression.py +209 -0
- app/core/middleware/request_id.py +72 -0
- app/core/middleware/required_capability_http.py +116 -0
- app/core/middleware/trusted_proxy_headers.py +36 -0
- app/core/multipart.py +237 -0
- app/core/multipart_fields.py +91 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/chat_requests.py +493 -0
- app/core/openai/chat_responses.py +931 -0
- app/core/openai/contracts.py +46 -0
- app/core/openai/exceptions.py +24 -0
- app/core/openai/host_models.py +14 -0
- app/core/openai/images.py +413 -0
- app/core/openai/message_coercion.py +376 -0
- app/core/openai/model_refresh_scheduler.py +423 -0
- app/core/openai/model_registry.py +997 -0
- app/core/openai/model_registry_store.py +548 -0
- app/core/openai/models.py +172 -0
- app/core/openai/parsing.py +97 -0
- app/core/openai/requests.py +1980 -0
- app/core/openai/strict_schema.py +199 -0
- app/core/openai/tool_call_safety.py +99 -0
- app/core/openai/v1_requests.py +187 -0
- app/core/plan_types.py +90 -0
- app/core/prestop.py +202 -0
- app/core/rate_limiter/__init__.py +0 -0
- app/core/rate_limiter/db_rate_limiter.py +215 -0
- app/core/request_locality.py +473 -0
- app/core/resilience/__init__.py +0 -0
- app/core/resilience/backpressure.py +57 -0
- app/core/resilience/bulkhead.py +202 -0
- app/core/resilience/circuit_breaker.py +150 -0
- app/core/resilience/degradation.py +55 -0
- app/core/resilience/loop_lag_monitor.py +60 -0
- app/core/resilience/memory_monitor.py +137 -0
- app/core/resilience/network_recovery.py +256 -0
- app/core/resilience/overload.py +98 -0
- app/core/resilience/toggles.py +114 -0
- app/core/retention/__init__.py +0 -0
- app/core/retention/job.py +240 -0
- app/core/retention/scheduler.py +69 -0
- app/core/runtime_logging.py +601 -0
- app/core/scheduling/__init__.py +0 -0
- app/core/scheduling/leader_election.py +993 -0
- app/core/scheduling/leader_election_handle.py +25 -0
- app/core/server.py +140 -0
- app/core/shutdown.py +326 -0
- app/core/socket_peer.py +22 -0
- app/core/startup.py +41 -0
- app/core/timeout_invariants.py +347 -0
- app/core/tracing/__init__.py +0 -0
- app/core/tracing/otel.py +207 -0
- app/core/types.py +6 -0
- app/core/upstream_proxy/__init__.py +13 -0
- app/core/upstream_proxy/cache.py +130 -0
- app/core/upstream_proxy/resolver.py +189 -0
- app/core/upstream_proxy/types.py +104 -0
- app/core/usage/__init__.py +407 -0
- app/core/usage/depletion.py +109 -0
- app/core/usage/live_hub.py +41 -0
- app/core/usage/live_snapshots.py +174 -0
- app/core/usage/logs.py +212 -0
- app/core/usage/metadata_scheduler.py +118 -0
- app/core/usage/models.py +54 -0
- app/core/usage/pricing.py +576 -0
- app/core/usage/pricing_catalog.py +241 -0
- app/core/usage/pricing_snapshot.json +821 -0
- app/core/usage/quota.py +133 -0
- app/core/usage/refresh_policy.py +21 -0
- app/core/usage/refresh_scheduler.py +713 -0
- app/core/usage/reset_credits_refresh_scheduler.py +435 -0
- app/core/usage/types.py +168 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/json_guards.py +18 -0
- app/core/utils/locks.py +33 -0
- app/core/utils/masking.py +20 -0
- app/core/utils/proxy_env.py +104 -0
- app/core/utils/request_id.py +65 -0
- app/core/utils/retry.py +77 -0
- app/core/utils/shared_future.py +172 -0
- app/core/utils/sse.py +286 -0
- app/core/utils/time.py +24 -0
- app/db/__init__.py +0 -0
- app/db/account_identity_lock.py +56 -0
- app/db/alembic/__init__.py +1 -0
- app/db/alembic/env.py +70 -0
- app/db/alembic/revision_ids.py +43 -0
- app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
- app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
- app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
- app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
- app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
- app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
- app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
- app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
- app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
- app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
- app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
- app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
- app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
- app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
- app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
- app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
- app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
- app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
- app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
- app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
- app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
- app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
- app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
- app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
- app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
- app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
- app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
- app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
- app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
- app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
- app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
- app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
- app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
- app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
- app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
- app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
- app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
- app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
- app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
- app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
- app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
- app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
- app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
- app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
- app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
- app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
- app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
- app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
- app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
- app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
- app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
- app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
- app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
- app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
- app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
- app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
- app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
- app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
- app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
- app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
- app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
- app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
- app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
- app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
- app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
- app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
- app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
- app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
- app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
- app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
- app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
- app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
- app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
- app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
- app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
- app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
- app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
- app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
- app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
- app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
- app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
- app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
- app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
- app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
- app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
- app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
- app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
- app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
- app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
- app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
- app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
- app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
- app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
- app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
- app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
- app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
- app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
- app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
- app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
- app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
- app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
- app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
- app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
- app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
- app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
- app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
- app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
- app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
- app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
- app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
- app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
- app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
- app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
- app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
- app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
- app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
- app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
- app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
- app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
- app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
- app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
- app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
- app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
- app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
- app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
- app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
- app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
- app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
- app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
- app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
- app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
- app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
- app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
- app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
- app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
- app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
- app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
- app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
- app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
- app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
- app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
- app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
- app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
- app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
- app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
- app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
- app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
- app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
- app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
- app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
- app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
- app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
- app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
- app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
- app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
- app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
- app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
- app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
- app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
- app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
- app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
- app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
- app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
- app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
- app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
- app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
- app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
- app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
- app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
- app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
- app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
- app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
- app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
- app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
- app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
- app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
- app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
- app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
- app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
- app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
- app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
- app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
- app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
- app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
- app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
- app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
- app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
- app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
- app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
- app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
- app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
- app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
- app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
- app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
- app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
- app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
- app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
- app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
- app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
- app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
- app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
- app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
- app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
- app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
- app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
- app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
- app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
- app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
- app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
- app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
- app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
- app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
- app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
- app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
- app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
- app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
- app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
- app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
- app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
- app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
- app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
- app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
- app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
- app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
- app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
- app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
- app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
- app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
- app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
- app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
- app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
- app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
- app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
- app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
- app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
- app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
- app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
- app/db/alembic/versions/__init__.py +1 -0
- app/db/backup.py +64 -0
- app/db/migrate.py +1249 -0
- app/db/migration_lock.py +160 -0
- app/db/migration_url.py +18 -0
- app/db/models.py +3100 -0
- app/db/recover.py +244 -0
- app/db/session.py +1295 -0
- app/db/snapshot.py +46 -0
- app/db/sqlite_lock_retry.py +190 -0
- app/db/sqlite_utils.py +549 -0
- app/dependencies.py +462 -0
- app/main.py +1240 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +426 -0
- app/modules/accounts/auth_manager.py +1439 -0
- app/modules/accounts/background_repository.py +160 -0
- app/modules/accounts/deletion.py +540 -0
- app/modules/accounts/mappers.py +654 -0
- app/modules/accounts/refresh_claims.py +352 -0
- app/modules/accounts/repository.py +1679 -0
- app/modules/accounts/schemas.py +308 -0
- app/modules/accounts/service.py +809 -0
- app/modules/accounts/usage_rollup.py +486 -0
- app/modules/accounts/usage_rollup_scheduler.py +75 -0
- app/modules/accounts/usage_time_rollup.py +1037 -0
- app/modules/accounts/usage_time_rollup_read.py +522 -0
- app/modules/api_keys/__init__.py +1 -0
- app/modules/api_keys/api.py +344 -0
- app/modules/api_keys/last_used_coalescer.py +216 -0
- app/modules/api_keys/limit_windows.py +41 -0
- app/modules/api_keys/repository.py +1200 -0
- app/modules/api_keys/reset_scheduler.py +85 -0
- app/modules/api_keys/schemas.py +135 -0
- app/modules/api_keys/service.py +2123 -0
- app/modules/audit/__init__.py +0 -0
- app/modules/audit/api.py +88 -0
- app/modules/audit/repository.py +74 -0
- app/modules/audit/schemas.py +33 -0
- app/modules/audit/service.py +71 -0
- app/modules/auth_providers/__init__.py +0 -0
- app/modules/auth_providers/api.py +127 -0
- app/modules/auth_providers/config.py +343 -0
- app/modules/auth_providers/repository.py +101 -0
- app/modules/auth_providers/schemas.py +88 -0
- app/modules/auth_providers/seed.py +105 -0
- app/modules/auth_providers/service.py +289 -0
- app/modules/automations/__init__.py +1 -0
- app/modules/automations/api.py +369 -0
- app/modules/automations/repository.py +1896 -0
- app/modules/automations/scheduler.py +107 -0
- app/modules/automations/schemas.py +158 -0
- app/modules/automations/service.py +2548 -0
- app/modules/cache_isolation_probe/__init__.py +0 -0
- app/modules/cache_isolation_probe/api.py +188 -0
- app/modules/cache_isolation_probe/prefix.py +144 -0
- app/modules/cache_isolation_probe/schemas.py +94 -0
- app/modules/cache_isolation_probe/sender.py +229 -0
- app/modules/cache_isolation_probe/service.py +525 -0
- app/modules/conversation_archive/__init__.py +1 -0
- app/modules/conversation_archive/api.py +101 -0
- app/modules/conversation_archive/schemas.py +28 -0
- app/modules/conversation_archive/service.py +165 -0
- app/modules/dashboard/__init__.py +1 -0
- app/modules/dashboard/api.py +106 -0
- app/modules/dashboard/builders.py +107 -0
- app/modules/dashboard/repository.py +304 -0
- app/modules/dashboard/schemas.py +148 -0
- app/modules/dashboard/service.py +534 -0
- app/modules/dashboard/timeframes.py +19 -0
- app/modules/dashboard/weekly_pace.py +617 -0
- app/modules/dashboard_auth/__init__.py +1 -0
- app/modules/dashboard_auth/api.py +1344 -0
- app/modules/dashboard_auth/oidc_api.py +753 -0
- app/modules/dashboard_auth/oidc_flows.py +330 -0
- app/modules/dashboard_auth/repository.py +452 -0
- app/modules/dashboard_auth/schemas.py +232 -0
- app/modules/dashboard_auth/service.py +1384 -0
- app/modules/dashboard_roles/__init__.py +0 -0
- app/modules/dashboard_roles/api.py +64 -0
- app/modules/dashboard_roles/repository.py +54 -0
- app/modules/dashboard_roles/schemas.py +29 -0
- app/modules/dashboard_roles/seed.py +80 -0
- app/modules/dashboard_roles/service.py +142 -0
- app/modules/dashboard_users/__init__.py +0 -0
- app/modules/dashboard_users/api.py +278 -0
- app/modules/dashboard_users/break_glass.py +169 -0
- app/modules/dashboard_users/credentials.py +18 -0
- app/modules/dashboard_users/identity_resolver.py +665 -0
- app/modules/dashboard_users/repository.py +725 -0
- app/modules/dashboard_users/schemas.py +117 -0
- app/modules/dashboard_users/service.py +1054 -0
- app/modules/firewall/__init__.py +1 -0
- app/modules/firewall/api.py +78 -0
- app/modules/firewall/repository.py +51 -0
- app/modules/firewall/schemas.py +28 -0
- app/modules/firewall/service.py +93 -0
- app/modules/fleet/__init__.py +1 -0
- app/modules/fleet/api.py +177 -0
- app/modules/fleet/mappers.py +56 -0
- app/modules/fleet/observability.py +372 -0
- app/modules/fleet/schemas.py +119 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +246 -0
- app/modules/health/schemas.py +27 -0
- app/modules/limit_warmup/__init__.py +1 -0
- app/modules/limit_warmup/repository.py +159 -0
- app/modules/limit_warmup/service.py +1001 -0
- app/modules/model_sources/__init__.py +1 -0
- app/modules/model_sources/api.py +99 -0
- app/modules/model_sources/catalog.py +307 -0
- app/modules/model_sources/forwarding.py +1435 -0
- app/modules/model_sources/projection.py +55 -0
- app/modules/model_sources/repository.py +201 -0
- app/modules/model_sources/schemas.py +79 -0
- app/modules/model_sources/selection.py +166 -0
- app/modules/model_sources/service.py +236 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +101 -0
- app/modules/oauth/repository.py +238 -0
- app/modules/oauth/schemas.py +49 -0
- app/modules/oauth/service.py +1196 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/_load_balancer/__init__.py +1 -0
- app/modules/proxy/_load_balancer/error_rate.py +116 -0
- app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
- app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
- app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
- app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
- app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
- app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
- app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
- app/modules/proxy/_load_balancer/tunables.py +97 -0
- app/modules/proxy/_load_balancer/types.py +96 -0
- app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
- app/modules/proxy/_service/__init__.py +6 -0
- app/modules/proxy/_service/api_key_usage.py +745 -0
- app/modules/proxy/_service/clock_budget.py +38 -0
- app/modules/proxy/_service/codex_control.py +611 -0
- app/modules/proxy/_service/compact.py +2211 -0
- app/modules/proxy/_service/file_ops.py +749 -0
- app/modules/proxy/_service/http_bridge/__init__.py +3 -0
- app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
- app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
- app/modules/proxy/_service/http_bridge/activity.py +178 -0
- app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
- app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
- app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
- app/modules/proxy/_service/http_bridge/protocol.py +167 -0
- app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
- app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
- app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
- app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
- app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
- app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
- app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
- app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
- app/modules/proxy/_service/observability.py +410 -0
- app/modules/proxy/_service/rate_limit.py +361 -0
- app/modules/proxy/_service/realtime_live.py +655 -0
- app/modules/proxy/_service/refresh.py +154 -0
- app/modules/proxy/_service/request_log.py +585 -0
- app/modules/proxy/_service/response_create.py +930 -0
- app/modules/proxy/_service/streaming/__init__.py +3 -0
- app/modules/proxy/_service/streaming/helpers.py +1317 -0
- app/modules/proxy/_service/streaming/mixin.py +1100 -0
- app/modules/proxy/_service/streaming/protocol.py +32 -0
- app/modules/proxy/_service/streaming/retry.py +3762 -0
- app/modules/proxy/_service/support.py +2122 -0
- app/modules/proxy/_service/transcribe.py +434 -0
- app/modules/proxy/_service/warmup.py +483 -0
- app/modules/proxy/_service/websocket/__init__.py +3 -0
- app/modules/proxy/_service/websocket/helpers.py +2360 -0
- app/modules/proxy/_service/websocket/mixin.py +7285 -0
- app/modules/proxy/_service/websocket/protocol.py +74 -0
- app/modules/proxy/account_cache.py +229 -0
- app/modules/proxy/account_eligibility.py +87 -0
- app/modules/proxy/additional_model_limits.py +40 -0
- app/modules/proxy/affinity.py +830 -0
- app/modules/proxy/affinity_observation.py +101 -0
- app/modules/proxy/api.py +10254 -0
- app/modules/proxy/api_key_usage.py +113 -0
- app/modules/proxy/cap_partitioning.py +280 -0
- app/modules/proxy/capability_lineage.py +65 -0
- app/modules/proxy/capability_lineage_repository.py +82 -0
- app/modules/proxy/capability_routing.py +253 -0
- app/modules/proxy/continuity.py +112 -0
- app/modules/proxy/downstream_delivery.py +277 -0
- app/modules/proxy/durable_bridge_coordinator.py +1217 -0
- app/modules/proxy/durable_bridge_repository.py +4516 -0
- app/modules/proxy/durable_bridge_runtime.py +9 -0
- app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
- app/modules/proxy/fair_share.py +131 -0
- app/modules/proxy/file_pin_repository.py +210 -0
- app/modules/proxy/helpers.py +439 -0
- app/modules/proxy/http_bridge_event_batcher.py +613 -0
- app/modules/proxy/http_bridge_forwarding.py +797 -0
- app/modules/proxy/http_continuation.py +76 -0
- app/modules/proxy/images_observability.py +62 -0
- app/modules/proxy/images_service.py +1054 -0
- app/modules/proxy/load_balancer.py +3016 -0
- app/modules/proxy/rate_limit_cache.py +46 -0
- app/modules/proxy/replay_safety.py +1063 -0
- app/modules/proxy/repo_bundle.py +31 -0
- app/modules/proxy/request_policy.py +1054 -0
- app/modules/proxy/ring_membership.py +251 -0
- app/modules/proxy/schemas.py +345 -0
- app/modules/proxy/selection_errors.py +41 -0
- app/modules/proxy/service.py +2600 -0
- app/modules/proxy/source_admission.py +125 -0
- app/modules/proxy/source_dispatch.py +999 -0
- app/modules/proxy/sticky_repository.py +831 -0
- app/modules/proxy/thread_anchors.py +491 -0
- app/modules/proxy/tool_call_dedupe.py +802 -0
- app/modules/proxy/types.py +54 -0
- app/modules/proxy/work_admission.py +121 -0
- app/modules/quota_planner/__init__.py +1 -0
- app/modules/quota_planner/api.py +276 -0
- app/modules/quota_planner/logic.py +743 -0
- app/modules/quota_planner/repository.py +722 -0
- app/modules/quota_planner/scheduler.py +212 -0
- app/modules/quota_planner/schemas.py +102 -0
- app/modules/quota_planner/warmup.py +693 -0
- app/modules/rate_limit_reset_credits/__init__.py +0 -0
- app/modules/rate_limit_reset_credits/api.py +601 -0
- app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
- app/modules/rate_limit_reset_credits/store.py +114 -0
- app/modules/reports/__init__.py +1 -0
- app/modules/reports/api.py +134 -0
- app/modules/reports/cache.py +74 -0
- app/modules/reports/filters.py +27 -0
- app/modules/reports/repository.py +525 -0
- app/modules/reports/rollup.py +150 -0
- app/modules/reports/rollup_read.py +148 -0
- app/modules/reports/schemas.py +123 -0
- app/modules/reports/service.py +287 -0
- app/modules/reports/thread_identity.py +256 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +224 -0
- app/modules/request_logs/cost_backfill.py +154 -0
- app/modules/request_logs/mappers.py +97 -0
- app/modules/request_logs/repository.py +1765 -0
- app/modules/request_logs/schemas.py +145 -0
- app/modules/request_logs/service.py +348 -0
- app/modules/role_mappings/__init__.py +0 -0
- app/modules/role_mappings/api.py +206 -0
- app/modules/role_mappings/matching.py +55 -0
- app/modules/role_mappings/repository.py +106 -0
- app/modules/role_mappings/schemas.py +67 -0
- app/modules/role_mappings/service.py +334 -0
- app/modules/runtime/__init__.py +1 -0
- app/modules/runtime/api.py +18 -0
- app/modules/runtime/schemas.py +14 -0
- app/modules/runtime/service.py +218 -0
- app/modules/scim/__init__.py +1 -0
- app/modules/scim/api.py +194 -0
- app/modules/scim/dependencies.py +112 -0
- app/modules/scim/errors.py +77 -0
- app/modules/scim/management_api.py +156 -0
- app/modules/scim/repository.py +212 -0
- app/modules/scim/schemas.py +145 -0
- app/modules/scim/service.py +786 -0
- app/modules/scim/tokens.py +43 -0
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +1677 -0
- app/modules/settings/repository.py +606 -0
- app/modules/settings/schemas.py +496 -0
- app/modules/settings/service.py +769 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +22 -0
- app/modules/sticky_sessions/__init__.py +1 -0
- app/modules/sticky_sessions/api.py +118 -0
- app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
- app/modules/sticky_sessions/schemas.py +78 -0
- app/modules/sticky_sessions/service.py +196 -0
- app/modules/telemetry/__init__.py +5 -0
- app/modules/telemetry/api.py +115 -0
- app/modules/telemetry/clients.py +62 -0
- app/modules/telemetry/consent.py +115 -0
- app/modules/telemetry/scheduler.py +85 -0
- app/modules/telemetry/schemas.py +163 -0
- app/modules/telemetry/sender.py +221 -0
- app/modules/telemetry/snapshot.py +490 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/additional_quota_keys.py +379 -0
- app/modules/usage/api.py +45 -0
- app/modules/usage/background_repository.py +147 -0
- app/modules/usage/builders.py +559 -0
- app/modules/usage/depletion_service.py +277 -0
- app/modules/usage/live_ingest.py +356 -0
- app/modules/usage/mappers.py +29 -0
- app/modules/usage/plan_downgrade_observations.py +488 -0
- app/modules/usage/repository.py +1890 -0
- app/modules/usage/schemas.py +68 -0
- app/modules/usage/service.py +102 -0
- app/modules/usage/updater.py +1488 -0
- app/static/assets/Trans-Cj-J9mVA.js +1 -0
- app/static/assets/access-page-DHzZiXZi.js +1 -0
- app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
- app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
- app/static/assets/account-status-DIsU5sUS.js +1 -0
- app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
- app/static/assets/accounts-page-Bbai8ift.js +3 -0
- app/static/assets/api-Dwa7XxPe.js +1 -0
- app/static/assets/api-client-dugX1Pti.js +55 -0
- app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
- app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
- app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
- app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
- app/static/assets/apis-page-BvXz9c6i.js +2 -0
- app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
- app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
- app/static/assets/automations-page-DQgNAJOR.js +3 -0
- app/static/assets/badge-DqMb9es0.js +1 -0
- app/static/assets/blur-email-HEvKagG2.js +1 -0
- app/static/assets/button-BjVmmiGW.js +1 -0
- app/static/assets/checkbox-apsl9vzk.js +1 -0
- app/static/assets/chevron-left-1d_pHVa5.js +1 -0
- app/static/assets/circle-alert-BS0xd_o1.js +1 -0
- app/static/assets/clsx-DB0hHKMi.js +1 -0
- app/static/assets/colors-BBy8GEtr.js +1 -0
- app/static/assets/copy-button-D7nAvOKR.js +1 -0
- app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
- app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
- app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
- app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
- app/static/assets/donut-chart-Bxc69cra.js +1 -0
- app/static/assets/download-kQteIcsB.js +1 -0
- app/static/assets/ellipsis-B_5OgIre.js +1 -0
- app/static/assets/empty-state-JcA7tc8m.js +1 -0
- app/static/assets/es2015-bMH8YKZT.js +41 -0
- app/static/assets/es6-B264ni6W.js +69 -0
- app/static/assets/external-link-UVAOgMQ0.js +1 -0
- app/static/assets/i18n-BWqZ9Ua2.js +1 -0
- app/static/assets/inbox-B_RM2s1J.js +1 -0
- app/static/assets/index-DIpd264Z.js +23 -0
- app/static/assets/index-DMeLKRJx.css +2 -0
- app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
- app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
- app/static/assets/iso-Bpe5Ihbb.js +1 -0
- app/static/assets/label-CGaLmLNf.js +1 -0
- app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
- app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
- app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
- app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
- app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
- app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
- app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
- app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
- app/static/assets/payload-CIYnGZz9.js +1 -0
- app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
- app/static/assets/report-chart-card-BXDliwcc.js +1 -0
- app/static/assets/reports-page-CGCJeMHG.js +3 -0
- app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
- app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- app/static/assets/search-HfhqwERt.js +1 -0
- app/static/assets/select-Cl33bz-g.js +1 -0
- app/static/assets/settings-page-mZj01HYl.js +2 -0
- app/static/assets/shim-CZwyM4Q7.js +1 -0
- app/static/assets/skeleton-C_EUc4Rp.js +1 -0
- app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
- app/static/assets/status-badge-CHHA5X6c.js +1 -0
- app/static/assets/switch-CzxgokcM.js +1 -0
- app/static/assets/table-DXaNSQ0G.js +1 -0
- app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
- app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
- app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
- app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
- app/static/assets/trash-D3KyWr1h.js +1 -0
- app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
- app/static/assets/use-accounts-PWlVGw3z.js +1 -0
- app/static/assets/use-auth-CuDlW3iC.js +1 -0
- app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
- app/static/assets/use-model-sources-Bul24JBu.js +1 -0
- app/static/assets/use-models-DUCPUuZg.js +1 -0
- app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
- app/static/assets/useTranslation--56nHhlH.js +1 -0
- app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
- app/static/assets/users-uIC3TIlE.js +1 -0
- app/static/assets/vendor-query-_bHZuxEx.js +1 -0
- app/static/assets/vendor-react-BVL0YZPW.js +9 -0
- app/static/favicon.svg +10 -0
- app/static/fonts/GeistSans-Variable.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- app/static/index.html +29 -0
- codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
- codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
- codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
- codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
- codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
- 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)}
|