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