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,1304 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, cast
|
|
16
|
+
|
|
17
|
+
from multidict import CIMultiDict
|
|
18
|
+
|
|
19
|
+
from app.core.clients.stream_errors import StreamEventTooLargeError, StreamIdleTimeoutError
|
|
20
|
+
from app.core.types import JsonValue
|
|
21
|
+
from app.core.utils.shared_future import _await_cleanup_deferring_cancellation
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_NATIVE_EGRESS_EXECUTABLE = "codex-lb-native-egress"
|
|
26
|
+
_NATIVE_PROTOCOL_VERSION = 1
|
|
27
|
+
_NATIVE_PROTOCOL_HANDSHAKE_TIMEOUT_SECONDS = 2.0
|
|
28
|
+
_REQUIRED_NATIVE_CAPABILITIES = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"failure_provenance_v1",
|
|
31
|
+
"http",
|
|
32
|
+
"http2_profile_v1",
|
|
33
|
+
"http_compact_collect_v1",
|
|
34
|
+
"http_compact_sse_v1",
|
|
35
|
+
"http_sse_v1",
|
|
36
|
+
"http_responses_events_v1",
|
|
37
|
+
"http_responses_completion_v1",
|
|
38
|
+
"websocket",
|
|
39
|
+
"websocket_responses_events_v1",
|
|
40
|
+
"websocket_responses_routing_v1",
|
|
41
|
+
"websocket_send_ack",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
_NATIVE_EVENT_LINE_LIMIT = 24 * 1024 * 1024
|
|
45
|
+
# Per-request bound between the helper reader (one task draining the helper's
|
|
46
|
+
# stdout for every in-flight request) and each request's consumer. The bound
|
|
47
|
+
# is a *byte budget* with a generous event cap: framed SSE deltas are tens of
|
|
48
|
+
# bytes each and arrive in bursts of hundreds when upstream flushes reasoning
|
|
49
|
+
# output, while a non-SSE body (an image generation JSON) is a handful of
|
|
50
|
+
# large chunks. Either shape must fit while a healthy consumer is merely late
|
|
51
|
+
# for a scheduling turn on a saturated event loop; only a consumer that truly
|
|
52
|
+
# stops draining (dead client) trips the bound and is failed so it cannot hold
|
|
53
|
+
# the shared reader hostage.
|
|
54
|
+
_NATIVE_STREAM_QUEUE_LIMIT = 4096
|
|
55
|
+
_NATIVE_STREAM_QUEUE_BYTES_LIMIT = 32 * 1024 * 1024
|
|
56
|
+
_NATIVE_WEBSOCKET_MESSAGE_QUEUE_LIMIT = 64
|
|
57
|
+
_NATIVE_CANCEL_TIMEOUT_SECONDS = 2.0
|
|
58
|
+
_NATIVE_WEBSOCKET_COMMAND_TIMEOUT_SECONDS = 30.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _event_payload_size(item: object) -> int:
|
|
62
|
+
"""Queued payload bytes of one helper event: its base64 ``data`` (ASCII, so
|
|
63
|
+
the string length is the byte length) or UTF-8 SSE text and type metadata."""
|
|
64
|
+
if not isinstance(item, dict):
|
|
65
|
+
return 0
|
|
66
|
+
data = item.get("data")
|
|
67
|
+
if isinstance(data, str):
|
|
68
|
+
return len(data)
|
|
69
|
+
text = item.get("text")
|
|
70
|
+
if isinstance(text, str):
|
|
71
|
+
kind = item.get("event_type")
|
|
72
|
+
metadata_size = len(kind.encode("utf-8")) if isinstance(kind, str) else 0
|
|
73
|
+
response_id = item.get("payload_response_id")
|
|
74
|
+
if isinstance(response_id, str):
|
|
75
|
+
metadata_size += len(response_id.encode("utf-8", errors="surrogatepass"))
|
|
76
|
+
sequence = item.get("sequence_number")
|
|
77
|
+
if isinstance(sequence, int):
|
|
78
|
+
metadata_size += len(str(sequence))
|
|
79
|
+
# Responses WebSocket IPC embeds the original JSON a second time as
|
|
80
|
+
# payload. Charge both copies without reserializing the decoded object.
|
|
81
|
+
copies = 2 if item.get("type") == "websocket_responses_text" else 1
|
|
82
|
+
return copies * len(text.encode("utf-8")) + metadata_size
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _BoundedEventQueue(asyncio.Queue[dict[str, object] | BaseException]):
|
|
87
|
+
"""``asyncio.Queue`` whose ``full()`` also trips on a queued-bytes budget.
|
|
88
|
+
|
|
89
|
+
``put_nowait`` consults ``full()`` before enqueueing, so the reader's
|
|
90
|
+
existing ``QueueFull`` handling covers both the event cap and the byte
|
|
91
|
+
budget without any change to the put/get call sites.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, *, max_events: int, max_bytes: int) -> None:
|
|
95
|
+
super().__init__(maxsize=max_events)
|
|
96
|
+
self._max_bytes = max_bytes
|
|
97
|
+
self.queued_bytes = 0
|
|
98
|
+
|
|
99
|
+
def put_nowait(self, item: dict[str, object] | BaseException) -> None:
|
|
100
|
+
# The byte budget is enforced against the projected total, so a queue
|
|
101
|
+
# just under budget rejects an event that would carry it past, while a
|
|
102
|
+
# zero-byte event (``end``/``error``/``cancelled`` or a failure object)
|
|
103
|
+
# is always accepted as long as the event cap has room so a complete
|
|
104
|
+
# response is never discarded at the boundary. An event arriving at an
|
|
105
|
+
# empty queue is always accepted (the SSE event size cap bounds it
|
|
106
|
+
# separately) so a lone large chunk is never a failure.
|
|
107
|
+
size = _event_payload_size(item)
|
|
108
|
+
if size and not self.empty() and self.queued_bytes + size > self._max_bytes:
|
|
109
|
+
raise asyncio.QueueFull
|
|
110
|
+
super().put_nowait(item)
|
|
111
|
+
|
|
112
|
+
def _put(self, item: dict[str, object] | BaseException) -> None:
|
|
113
|
+
super()._put(item)
|
|
114
|
+
self.queued_bytes += _event_payload_size(item)
|
|
115
|
+
|
|
116
|
+
def _get(self) -> dict[str, object] | BaseException:
|
|
117
|
+
item = super()._get()
|
|
118
|
+
self.queued_bytes -= _event_payload_size(item)
|
|
119
|
+
return item
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _new_stream_queue() -> _BoundedEventQueue:
|
|
123
|
+
return _BoundedEventQueue(max_events=_NATIVE_STREAM_QUEUE_LIMIT, max_bytes=_NATIVE_STREAM_QUEUE_BYTES_LIMIT)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class NativeEgressError(Exception):
|
|
127
|
+
"""Base error for the optional native Codex egress boundary."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class NativeEgressUnavailable(NativeEgressError):
|
|
131
|
+
"""Raised when the optional native helper is not installed."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class NativeEgressProtocolError(NativeEgressError):
|
|
135
|
+
"""Raised when the helper violates the framed stream contract."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class NativeEgressTransportError(NativeEgressError):
|
|
139
|
+
"""Raised when the helper cannot complete the upstream request."""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
message: str,
|
|
144
|
+
*,
|
|
145
|
+
failure_phase: str = "request",
|
|
146
|
+
retryable_same_contract: bool = False,
|
|
147
|
+
is_tls_verification_failure: bool = False,
|
|
148
|
+
status_code: int | None = None,
|
|
149
|
+
headers: tuple[tuple[str, str], ...] = (),
|
|
150
|
+
body: bytes | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
super().__init__(message)
|
|
153
|
+
self.failure_phase = failure_phase
|
|
154
|
+
self.retryable_same_contract = retryable_same_contract
|
|
155
|
+
self.is_tls_verification_failure = is_tls_verification_failure
|
|
156
|
+
self.status_code = status_code
|
|
157
|
+
self.headers = headers
|
|
158
|
+
self.body = body
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, slots=True)
|
|
162
|
+
class NativeSseOptions:
|
|
163
|
+
idle_timeout_seconds: float
|
|
164
|
+
max_event_bytes: int
|
|
165
|
+
content_type_aware: bool = False
|
|
166
|
+
collect_compact: bool = False
|
|
167
|
+
interpret_responses: bool = False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class NativeEgressRequest:
|
|
172
|
+
method: str
|
|
173
|
+
url: str
|
|
174
|
+
headers: Mapping[str, str]
|
|
175
|
+
body: bytes | None = None
|
|
176
|
+
timeout_seconds: float | None = 60.0
|
|
177
|
+
connect_timeout_seconds: float | None = None
|
|
178
|
+
response_head_timeout_seconds: float | None = None
|
|
179
|
+
proxy_url: str | None = None
|
|
180
|
+
sse: NativeSseOptions | None = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class NativeWebSocketRequest:
|
|
185
|
+
url: str
|
|
186
|
+
headers: Mapping[str, str]
|
|
187
|
+
connect_timeout_seconds: float
|
|
188
|
+
max_message_bytes: int
|
|
189
|
+
ping_interval_seconds: float | None = 20.0
|
|
190
|
+
ping_timeout_seconds: float | None = None
|
|
191
|
+
proxy_url: str | None = None
|
|
192
|
+
interpret_responses: bool = False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True, slots=True)
|
|
196
|
+
class NativeWebSocketRoutingMetadata:
|
|
197
|
+
payload_response_id: str | None
|
|
198
|
+
sequence_number: int | None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class NativeWebSocketMessage:
|
|
203
|
+
kind: str
|
|
204
|
+
text: str | None = None
|
|
205
|
+
data: bytes | None = None
|
|
206
|
+
close_code: int | None = None
|
|
207
|
+
close_reason: str | None = None
|
|
208
|
+
responses_interpreted: bool = False
|
|
209
|
+
event_type: str | None = None
|
|
210
|
+
payload: dict[str, JsonValue] | None = None
|
|
211
|
+
routing: NativeWebSocketRoutingMetadata | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class NativeEgressClient(Protocol):
|
|
215
|
+
async def request(self, request: NativeEgressRequest) -> NativeEgressResponse: ...
|
|
216
|
+
|
|
217
|
+
async def websocket(self, request: NativeWebSocketRequest) -> NativeEgressWebSocket: ...
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class NativeResponsesEvent(str):
|
|
221
|
+
"""A complete SSE block with interpretation metadata from the Rust owner."""
|
|
222
|
+
|
|
223
|
+
event_type: str | None
|
|
224
|
+
python_normalization: bool
|
|
225
|
+
|
|
226
|
+
def __new__(cls, text: str, event_type: str | None, python_normalization: bool) -> NativeResponsesEvent:
|
|
227
|
+
block = super().__new__(cls, text)
|
|
228
|
+
block.event_type = event_type
|
|
229
|
+
block.python_normalization = python_normalization
|
|
230
|
+
return block
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class NativeEgressResponse:
|
|
234
|
+
"""One response stream multiplexed through a persistent native helper."""
|
|
235
|
+
|
|
236
|
+
reason: str | None = None
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
status: int,
|
|
242
|
+
http_version: str,
|
|
243
|
+
headers: tuple[tuple[str, str], ...],
|
|
244
|
+
client: SubprocessNativeEgressClient,
|
|
245
|
+
request_id: str,
|
|
246
|
+
generation: int,
|
|
247
|
+
events: asyncio.Queue[dict[str, object] | BaseException],
|
|
248
|
+
sse_framed: bool = False,
|
|
249
|
+
compact_collected: bool = False,
|
|
250
|
+
responses_interpreted: bool = False,
|
|
251
|
+
) -> None:
|
|
252
|
+
self.status = status
|
|
253
|
+
self.http_version = http_version
|
|
254
|
+
self.raw_headers = headers
|
|
255
|
+
self.headers: CIMultiDict[str] = CIMultiDict(headers)
|
|
256
|
+
self.content = _NativeEgressContent(self)
|
|
257
|
+
self.sse_framed = sse_framed
|
|
258
|
+
self.compact_collected = compact_collected
|
|
259
|
+
self.responses_interpreted = responses_interpreted
|
|
260
|
+
self._client = client
|
|
261
|
+
self._request_id = request_id
|
|
262
|
+
self._generation = generation
|
|
263
|
+
self._events = events
|
|
264
|
+
self._iterated = False
|
|
265
|
+
self._completed = False
|
|
266
|
+
self._body_cache: bytes | None = None
|
|
267
|
+
|
|
268
|
+
async def iter_bytes(self) -> AsyncIterator[bytes]:
|
|
269
|
+
if self.sse_framed:
|
|
270
|
+
raise NativeEgressProtocolError("native SSE response requires framed consumption")
|
|
271
|
+
async with contextlib.aclosing(self._iter_body_events("chunk")) as events:
|
|
272
|
+
async for event in events:
|
|
273
|
+
encoded = event.get("data")
|
|
274
|
+
if not isinstance(encoded, str):
|
|
275
|
+
raise NativeEgressProtocolError("native chunk is missing base64 data")
|
|
276
|
+
try:
|
|
277
|
+
yield base64.b64decode(encoded, validate=True)
|
|
278
|
+
except ValueError as exc:
|
|
279
|
+
raise NativeEgressProtocolError("native chunk contains invalid base64 data") from exc
|
|
280
|
+
|
|
281
|
+
async def iter_sse_events(self) -> AsyncGenerator[str, None]:
|
|
282
|
+
if not self.sse_framed or self.compact_collected:
|
|
283
|
+
raise NativeEgressProtocolError("native response did not request SSE framing")
|
|
284
|
+
event_kind = "responses_event" if self.responses_interpreted else "sse"
|
|
285
|
+
async with contextlib.aclosing(self._iter_text_results(event_kind)) as results:
|
|
286
|
+
async for text in results:
|
|
287
|
+
yield text
|
|
288
|
+
|
|
289
|
+
async def compact_result(self) -> object:
|
|
290
|
+
if not self.compact_collected:
|
|
291
|
+
raise NativeEgressProtocolError("native response did not request compact collection")
|
|
292
|
+
result: object = None
|
|
293
|
+
received = False
|
|
294
|
+
async with contextlib.aclosing(self._iter_text_results("compact")) as results:
|
|
295
|
+
async for text in results:
|
|
296
|
+
if received:
|
|
297
|
+
raise NativeEgressProtocolError("native compact response contains multiple results")
|
|
298
|
+
try:
|
|
299
|
+
result = json.loads(text)
|
|
300
|
+
except ValueError as exc:
|
|
301
|
+
raise NativeEgressProtocolError("native compact result is invalid JSON") from exc
|
|
302
|
+
received = True
|
|
303
|
+
if not received:
|
|
304
|
+
raise NativeEgressProtocolError("native compact response ended without a result")
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
async def _iter_text_results(self, event_type: str) -> AsyncGenerator[str, None]:
|
|
308
|
+
fragments: list[str] = []
|
|
309
|
+
async with contextlib.aclosing(self._iter_body_events(event_type)) as events:
|
|
310
|
+
async for event in events:
|
|
311
|
+
text = event.get("text")
|
|
312
|
+
more = event.get("more")
|
|
313
|
+
if not isinstance(text, str) or type(more) is not bool:
|
|
314
|
+
raise NativeEgressProtocolError(f"native {event_type} fragment has an invalid shape")
|
|
315
|
+
if event_type in {"compact", "responses_event"} and len(text.encode("utf-8")) > 16 * 1024:
|
|
316
|
+
raise NativeEgressProtocolError(f"native {event_type} fragment exceeds the text limit")
|
|
317
|
+
if event_type == "responses_event":
|
|
318
|
+
kind = event.get("event_type")
|
|
319
|
+
python_normalization = event.get("python_normalization")
|
|
320
|
+
stream_complete = event.get("stream_complete", False)
|
|
321
|
+
if (
|
|
322
|
+
"event_type" not in event
|
|
323
|
+
or (kind is not None and not isinstance(kind, str))
|
|
324
|
+
or (isinstance(kind, str) and len(kind.encode("utf-8")) > 16 * 1024)
|
|
325
|
+
or type(python_normalization) is not bool
|
|
326
|
+
or type(stream_complete) is not bool
|
|
327
|
+
or (more and (kind is not None or python_normalization))
|
|
328
|
+
or (
|
|
329
|
+
stream_complete
|
|
330
|
+
and (more or kind not in {"response.completed", "response.failed", "response.incomplete"})
|
|
331
|
+
)
|
|
332
|
+
):
|
|
333
|
+
raise NativeEgressProtocolError("native Responses event has invalid metadata")
|
|
334
|
+
if not more:
|
|
335
|
+
text = "".join(fragments) + text
|
|
336
|
+
fragments.clear()
|
|
337
|
+
if stream_complete:
|
|
338
|
+
self._completed = True
|
|
339
|
+
self._client._finish_request(self._request_id, self._generation, self._events)
|
|
340
|
+
yield NativeResponsesEvent(text, kind, python_normalization)
|
|
341
|
+
if stream_complete:
|
|
342
|
+
return
|
|
343
|
+
continue
|
|
344
|
+
if more:
|
|
345
|
+
fragments.append(text)
|
|
346
|
+
elif fragments:
|
|
347
|
+
fragments.append(text)
|
|
348
|
+
block = "".join(fragments)
|
|
349
|
+
fragments.clear()
|
|
350
|
+
yield block
|
|
351
|
+
else:
|
|
352
|
+
yield text
|
|
353
|
+
if fragments:
|
|
354
|
+
raise NativeEgressProtocolError(f"native {event_type} response ended during an event")
|
|
355
|
+
|
|
356
|
+
async def _iter_body_events(self, expected_type: str) -> AsyncGenerator[dict[str, object], None]:
|
|
357
|
+
if self._iterated:
|
|
358
|
+
raise NativeEgressProtocolError("native response body can only be consumed once")
|
|
359
|
+
self._iterated = True
|
|
360
|
+
try:
|
|
361
|
+
while True:
|
|
362
|
+
item = await self._events.get()
|
|
363
|
+
if isinstance(item, BaseException):
|
|
364
|
+
self._completed = True
|
|
365
|
+
self._client._finish_request(self._request_id, self._generation, self._events)
|
|
366
|
+
raise item
|
|
367
|
+
event = item
|
|
368
|
+
event_type = event.get("type")
|
|
369
|
+
if event_type == expected_type:
|
|
370
|
+
yield event
|
|
371
|
+
continue
|
|
372
|
+
if event_type == "end":
|
|
373
|
+
self._completed = True
|
|
374
|
+
self._client._finish_request(self._request_id, self._generation, self._events)
|
|
375
|
+
return
|
|
376
|
+
if event_type == "error":
|
|
377
|
+
self._completed = True
|
|
378
|
+
self._client._finish_request(self._request_id, self._generation, self._events)
|
|
379
|
+
if self.sse_framed and event.get("failure_phase") == "stream_idle_timeout":
|
|
380
|
+
raise StreamIdleTimeoutError()
|
|
381
|
+
raise _transport_error_from_event(event)
|
|
382
|
+
if self.sse_framed and event_type == "sse_event_too_large":
|
|
383
|
+
size = event.get("size_bytes")
|
|
384
|
+
limit = event.get("limit_bytes")
|
|
385
|
+
if type(size) is not int or type(limit) is not int or not 0 < limit < size:
|
|
386
|
+
raise NativeEgressProtocolError("native SSE size failure has an invalid shape")
|
|
387
|
+
self._completed = True
|
|
388
|
+
self._client._finish_request(self._request_id, self._generation, self._events)
|
|
389
|
+
raise StreamEventTooLargeError(size, limit)
|
|
390
|
+
if event_type == "cancelled":
|
|
391
|
+
self._completed = True
|
|
392
|
+
self._client._finish_request(self._request_id, self._generation, self._events)
|
|
393
|
+
raise NativeEgressTransportError(
|
|
394
|
+
"native request was cancelled",
|
|
395
|
+
failure_phase="cancelled",
|
|
396
|
+
)
|
|
397
|
+
raise NativeEgressProtocolError(f"unexpected native stream event: {event_type!r}")
|
|
398
|
+
finally:
|
|
399
|
+
if not self._completed:
|
|
400
|
+
await self.aclose()
|
|
401
|
+
|
|
402
|
+
async def read(self) -> bytes:
|
|
403
|
+
if self._body_cache is None:
|
|
404
|
+
self._body_cache = b"".join([chunk async for chunk in self.iter_bytes()])
|
|
405
|
+
return self._body_cache
|
|
406
|
+
|
|
407
|
+
async def text(self, *, encoding: str = "utf-8", errors: str = "replace") -> str:
|
|
408
|
+
return (await self.read()).decode(encoding, errors=errors)
|
|
409
|
+
|
|
410
|
+
async def json(self, *, content_type: str | None = None) -> object:
|
|
411
|
+
del content_type
|
|
412
|
+
return json.loads(await self.read())
|
|
413
|
+
|
|
414
|
+
async def aclose(self) -> None:
|
|
415
|
+
if self._completed:
|
|
416
|
+
return
|
|
417
|
+
self._completed = True
|
|
418
|
+
cancellation = await _await_cleanup_deferring_cancellation(
|
|
419
|
+
self._client._cancel_request(self._request_id, self._generation, self._events)
|
|
420
|
+
)
|
|
421
|
+
if cancellation is not None:
|
|
422
|
+
raise cancellation
|
|
423
|
+
|
|
424
|
+
async def __aenter__(self) -> NativeEgressResponse:
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
428
|
+
await self.aclose()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class NativeEgressWebSocket:
|
|
432
|
+
"""Bidirectional WebSocket multiplexed through one native helper stream."""
|
|
433
|
+
|
|
434
|
+
def __init__(
|
|
435
|
+
self,
|
|
436
|
+
*,
|
|
437
|
+
status: int,
|
|
438
|
+
headers: tuple[tuple[str, str], ...],
|
|
439
|
+
client: SubprocessNativeEgressClient,
|
|
440
|
+
process: asyncio.subprocess.Process,
|
|
441
|
+
request_id: str,
|
|
442
|
+
generation: int,
|
|
443
|
+
events: asyncio.Queue[dict[str, object] | BaseException],
|
|
444
|
+
) -> None:
|
|
445
|
+
self.status = status
|
|
446
|
+
self.raw_headers = headers
|
|
447
|
+
self.headers: CIMultiDict[str] = CIMultiDict(headers)
|
|
448
|
+
self._client = client
|
|
449
|
+
self._process = process
|
|
450
|
+
self._request_id = request_id
|
|
451
|
+
self._generation = generation
|
|
452
|
+
self._events = events
|
|
453
|
+
self._messages: asyncio.Queue[NativeWebSocketMessage | BaseException] = asyncio.Queue(
|
|
454
|
+
maxsize=_NATIVE_WEBSOCKET_MESSAGE_QUEUE_LIMIT
|
|
455
|
+
)
|
|
456
|
+
self._pending: dict[str, asyncio.Future[None]] = {}
|
|
457
|
+
self._command_sequence = 0
|
|
458
|
+
self._completed = False
|
|
459
|
+
self._closing = False
|
|
460
|
+
self._remote_closed = False
|
|
461
|
+
self._terminal_failure: BaseException | None = None
|
|
462
|
+
self._pump_task = asyncio.create_task(
|
|
463
|
+
self._pump(),
|
|
464
|
+
name=f"native-websocket-pump-{request_id}",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
async def send_text(self, text: str) -> None:
|
|
468
|
+
await self._send("websocket_send_text", text=text)
|
|
469
|
+
|
|
470
|
+
async def send_bytes(self, data: bytes) -> None:
|
|
471
|
+
await self._send(
|
|
472
|
+
"websocket_send_binary",
|
|
473
|
+
data=base64.b64encode(data).decode("ascii"),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
async def receive(self) -> NativeWebSocketMessage:
|
|
477
|
+
if self._completed and self._messages.empty():
|
|
478
|
+
raise self._terminal_failure or NativeEgressTransportError(
|
|
479
|
+
"native websocket is closed",
|
|
480
|
+
failure_phase="websocket_receive",
|
|
481
|
+
)
|
|
482
|
+
item = await self._messages.get()
|
|
483
|
+
if isinstance(item, BaseException):
|
|
484
|
+
raise item
|
|
485
|
+
return item
|
|
486
|
+
|
|
487
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
488
|
+
if self._completed or self._closing:
|
|
489
|
+
return
|
|
490
|
+
self._closing = True
|
|
491
|
+
try:
|
|
492
|
+
try:
|
|
493
|
+
await self._send(
|
|
494
|
+
"websocket_close",
|
|
495
|
+
allow_closing=True,
|
|
496
|
+
code=code,
|
|
497
|
+
reason=reason,
|
|
498
|
+
)
|
|
499
|
+
await asyncio.wait_for(
|
|
500
|
+
asyncio.shield(self._pump_task),
|
|
501
|
+
timeout=_NATIVE_CANCEL_TIMEOUT_SECONDS,
|
|
502
|
+
)
|
|
503
|
+
except NativeEgressTransportError:
|
|
504
|
+
# A peer close can race with a local close command. The helper
|
|
505
|
+
# removes the socket immediately after forwarding the peer's
|
|
506
|
+
# close frame, so the command may receive "not active" even
|
|
507
|
+
# though the connection already ended cleanly.
|
|
508
|
+
if self._remote_closed:
|
|
509
|
+
return
|
|
510
|
+
if not self._pump_task.done():
|
|
511
|
+
self._pump_task.cancel()
|
|
512
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
513
|
+
await self._pump_task
|
|
514
|
+
await self._client._cancel_request(
|
|
515
|
+
self._request_id,
|
|
516
|
+
self._generation,
|
|
517
|
+
self._events,
|
|
518
|
+
)
|
|
519
|
+
raise
|
|
520
|
+
except BaseException:
|
|
521
|
+
if not self._pump_task.done():
|
|
522
|
+
self._pump_task.cancel()
|
|
523
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
524
|
+
await self._pump_task
|
|
525
|
+
await self._client._cancel_request(
|
|
526
|
+
self._request_id,
|
|
527
|
+
self._generation,
|
|
528
|
+
self._events,
|
|
529
|
+
)
|
|
530
|
+
raise
|
|
531
|
+
finally:
|
|
532
|
+
if not self._pump_task.done():
|
|
533
|
+
self._pump_task.cancel()
|
|
534
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
535
|
+
await self._pump_task
|
|
536
|
+
self._finish()
|
|
537
|
+
|
|
538
|
+
def response_header(self, name: str) -> str | None:
|
|
539
|
+
return self.headers.get(name)
|
|
540
|
+
|
|
541
|
+
async def _send(self, event_type: str, *, allow_closing: bool = False, **payload: object) -> None:
|
|
542
|
+
if self._completed or (self._closing and not allow_closing):
|
|
543
|
+
raise NativeEgressTransportError(
|
|
544
|
+
"native websocket is closed",
|
|
545
|
+
failure_phase="websocket_send",
|
|
546
|
+
)
|
|
547
|
+
self._command_sequence += 1
|
|
548
|
+
command_id = f"{self._request_id}:{self._command_sequence}"
|
|
549
|
+
future = asyncio.get_running_loop().create_future()
|
|
550
|
+
self._pending[command_id] = future
|
|
551
|
+
try:
|
|
552
|
+
await self._client._send_command(
|
|
553
|
+
self._process,
|
|
554
|
+
self._generation,
|
|
555
|
+
{
|
|
556
|
+
"type": event_type,
|
|
557
|
+
"request_id": self._request_id,
|
|
558
|
+
"command_id": command_id,
|
|
559
|
+
**payload,
|
|
560
|
+
},
|
|
561
|
+
)
|
|
562
|
+
await asyncio.wait_for(
|
|
563
|
+
asyncio.shield(future),
|
|
564
|
+
timeout=_NATIVE_WEBSOCKET_COMMAND_TIMEOUT_SECONDS,
|
|
565
|
+
)
|
|
566
|
+
except TimeoutError as exc:
|
|
567
|
+
raise NativeEgressTransportError(
|
|
568
|
+
"native websocket send acknowledgement timed out",
|
|
569
|
+
failure_phase="websocket_send",
|
|
570
|
+
) from exc
|
|
571
|
+
finally:
|
|
572
|
+
self._pending.pop(command_id, None)
|
|
573
|
+
|
|
574
|
+
async def _pump(self) -> None:
|
|
575
|
+
terminal_failure: BaseException | None = None
|
|
576
|
+
try:
|
|
577
|
+
while True:
|
|
578
|
+
item = await self._events.get()
|
|
579
|
+
if isinstance(item, BaseException):
|
|
580
|
+
terminal_failure = item
|
|
581
|
+
return
|
|
582
|
+
event_type = item.get("type")
|
|
583
|
+
if event_type == "websocket_sent":
|
|
584
|
+
command_id = item.get("command_id")
|
|
585
|
+
if not isinstance(command_id, str):
|
|
586
|
+
raise NativeEgressProtocolError("native websocket acknowledgement is missing command_id")
|
|
587
|
+
future = self._pending.get(command_id)
|
|
588
|
+
if future is not None and not future.done():
|
|
589
|
+
future.set_result(None)
|
|
590
|
+
continue
|
|
591
|
+
if event_type == "websocket_text":
|
|
592
|
+
text = item.get("text")
|
|
593
|
+
if not isinstance(text, str):
|
|
594
|
+
raise NativeEgressProtocolError("native websocket text event is invalid")
|
|
595
|
+
self._queue_message(NativeWebSocketMessage(kind="text", text=text))
|
|
596
|
+
continue
|
|
597
|
+
if event_type == "websocket_responses_text":
|
|
598
|
+
text = item.get("text")
|
|
599
|
+
kind = item.get("event_type")
|
|
600
|
+
payload = item.get("payload")
|
|
601
|
+
response_id = item.get("payload_response_id")
|
|
602
|
+
sequence = item.get("sequence_number")
|
|
603
|
+
if (
|
|
604
|
+
not isinstance(text, str)
|
|
605
|
+
or "event_type" not in item
|
|
606
|
+
or (kind is not None and not isinstance(kind, str))
|
|
607
|
+
or not isinstance(payload, dict)
|
|
608
|
+
or "payload_response_id" not in item
|
|
609
|
+
or (response_id is not None and not isinstance(response_id, str))
|
|
610
|
+
or "sequence_number" not in item
|
|
611
|
+
or (sequence is not None and type(sequence) is not int)
|
|
612
|
+
):
|
|
613
|
+
raise NativeEgressProtocolError("native Responses websocket event is invalid")
|
|
614
|
+
self._queue_message(
|
|
615
|
+
NativeWebSocketMessage(
|
|
616
|
+
kind="text",
|
|
617
|
+
text=text,
|
|
618
|
+
responses_interpreted=True,
|
|
619
|
+
event_type=kind,
|
|
620
|
+
payload=cast(dict[str, JsonValue], payload),
|
|
621
|
+
routing=NativeWebSocketRoutingMetadata(response_id, sequence),
|
|
622
|
+
)
|
|
623
|
+
)
|
|
624
|
+
continue
|
|
625
|
+
if event_type == "websocket_binary":
|
|
626
|
+
encoded = item.get("data")
|
|
627
|
+
if not isinstance(encoded, str):
|
|
628
|
+
raise NativeEgressProtocolError("native websocket binary event is invalid")
|
|
629
|
+
try:
|
|
630
|
+
data = base64.b64decode(encoded, validate=True)
|
|
631
|
+
except ValueError as exc:
|
|
632
|
+
raise NativeEgressProtocolError("native websocket binary event is not base64") from exc
|
|
633
|
+
self._queue_message(NativeWebSocketMessage(kind="binary", data=data))
|
|
634
|
+
continue
|
|
635
|
+
if event_type == "websocket_close":
|
|
636
|
+
code = item.get("code")
|
|
637
|
+
reason = item.get("reason")
|
|
638
|
+
if code is not None and not isinstance(code, int):
|
|
639
|
+
raise NativeEgressProtocolError("native websocket close code is invalid")
|
|
640
|
+
if reason is not None and not isinstance(reason, str):
|
|
641
|
+
raise NativeEgressProtocolError("native websocket close reason is invalid")
|
|
642
|
+
self._remote_closed = True
|
|
643
|
+
self._queue_message(
|
|
644
|
+
NativeWebSocketMessage(
|
|
645
|
+
kind="close",
|
|
646
|
+
close_code=code,
|
|
647
|
+
close_reason=reason,
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
terminal_failure = NativeEgressTransportError(
|
|
651
|
+
"native websocket closed",
|
|
652
|
+
failure_phase="websocket_receive",
|
|
653
|
+
)
|
|
654
|
+
return
|
|
655
|
+
if event_type == "websocket_error":
|
|
656
|
+
terminal_failure = _websocket_error_from_event(item)
|
|
657
|
+
return
|
|
658
|
+
if event_type == "cancelled":
|
|
659
|
+
terminal_failure = NativeEgressTransportError(
|
|
660
|
+
"native websocket was cancelled",
|
|
661
|
+
failure_phase="cancelled",
|
|
662
|
+
)
|
|
663
|
+
return
|
|
664
|
+
raise NativeEgressProtocolError(f"unexpected native websocket event: {event_type!r}")
|
|
665
|
+
except asyncio.CancelledError:
|
|
666
|
+
terminal_failure = NativeEgressTransportError(
|
|
667
|
+
"native websocket was closed locally",
|
|
668
|
+
failure_phase="cancelled",
|
|
669
|
+
)
|
|
670
|
+
raise
|
|
671
|
+
except BaseException as exc:
|
|
672
|
+
terminal_failure = exc
|
|
673
|
+
await self._client._abort_request(
|
|
674
|
+
self._request_id,
|
|
675
|
+
self._generation,
|
|
676
|
+
self._events,
|
|
677
|
+
)
|
|
678
|
+
finally:
|
|
679
|
+
if terminal_failure is None and not self._closing:
|
|
680
|
+
terminal_failure = NativeEgressTransportError(
|
|
681
|
+
"native websocket closed",
|
|
682
|
+
failure_phase="websocket_receive",
|
|
683
|
+
)
|
|
684
|
+
if terminal_failure is not None:
|
|
685
|
+
self._fail_pending(terminal_failure)
|
|
686
|
+
self._queue_terminal(terminal_failure)
|
|
687
|
+
self._finish()
|
|
688
|
+
|
|
689
|
+
def _queue_message(self, message: NativeWebSocketMessage) -> None:
|
|
690
|
+
try:
|
|
691
|
+
self._messages.put_nowait(message)
|
|
692
|
+
except asyncio.QueueFull as exc:
|
|
693
|
+
while not self._messages.empty():
|
|
694
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
695
|
+
self._messages.get_nowait()
|
|
696
|
+
raise NativeEgressTransportError(
|
|
697
|
+
"native websocket consumer exceeded the bounded message queue",
|
|
698
|
+
failure_phase="consumer_backpressure",
|
|
699
|
+
) from exc
|
|
700
|
+
|
|
701
|
+
def _queue_terminal(self, failure: BaseException) -> None:
|
|
702
|
+
self._terminal_failure = failure
|
|
703
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
704
|
+
self._messages.put_nowait(failure)
|
|
705
|
+
|
|
706
|
+
def _fail_pending(self, failure: BaseException) -> None:
|
|
707
|
+
for future in tuple(self._pending.values()):
|
|
708
|
+
if not future.done():
|
|
709
|
+
future.set_exception(failure)
|
|
710
|
+
|
|
711
|
+
def _finish(self) -> None:
|
|
712
|
+
if self._completed:
|
|
713
|
+
return
|
|
714
|
+
self._completed = True
|
|
715
|
+
self._client._finish_request(
|
|
716
|
+
self._request_id,
|
|
717
|
+
self._generation,
|
|
718
|
+
self._events,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class _NativeEgressContent:
|
|
723
|
+
def __init__(self, response: NativeEgressResponse) -> None:
|
|
724
|
+
self._response = response
|
|
725
|
+
|
|
726
|
+
def iter_chunked(self, size: int) -> AsyncIterator[bytes]:
|
|
727
|
+
del size
|
|
728
|
+
return self._response.iter_bytes()
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
class SubprocessNativeEgressClient:
|
|
732
|
+
"""Persistent multiplexed adapter for the pinned Rust Codex helper.
|
|
733
|
+
|
|
734
|
+
The helper is deliberately discovered by an explicit executable path. Merely
|
|
735
|
+
importing codex-lb never builds, installs, or requires a Rust component.
|
|
736
|
+
"""
|
|
737
|
+
|
|
738
|
+
def __init__(self, executable: str | os.PathLike[str]) -> None:
|
|
739
|
+
self.executable = Path(executable)
|
|
740
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
741
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
742
|
+
self._generation = 0
|
|
743
|
+
self._request_sequence = 0
|
|
744
|
+
self._streams: dict[
|
|
745
|
+
str,
|
|
746
|
+
tuple[int, asyncio.Queue[dict[str, object] | BaseException]],
|
|
747
|
+
] = {}
|
|
748
|
+
self._start_lock = asyncio.Lock()
|
|
749
|
+
self._write_lock = asyncio.Lock()
|
|
750
|
+
self._closed = False
|
|
751
|
+
|
|
752
|
+
@property
|
|
753
|
+
def available(self) -> bool:
|
|
754
|
+
return self.executable.is_file() and os.access(self.executable, os.X_OK)
|
|
755
|
+
|
|
756
|
+
async def request(self, request: NativeEgressRequest) -> NativeEgressResponse:
|
|
757
|
+
if not self.available:
|
|
758
|
+
raise NativeEgressUnavailable(f"native egress helper is unavailable: {self.executable}")
|
|
759
|
+
if request.timeout_seconds is not None and request.timeout_seconds <= 0:
|
|
760
|
+
raise ValueError("native egress timeout_seconds must be positive")
|
|
761
|
+
if request.connect_timeout_seconds is not None and request.connect_timeout_seconds <= 0:
|
|
762
|
+
raise ValueError("native egress connect_timeout_seconds must be positive")
|
|
763
|
+
if request.response_head_timeout_seconds is not None and request.response_head_timeout_seconds <= 0:
|
|
764
|
+
raise ValueError("native egress response_head_timeout_seconds must be positive")
|
|
765
|
+
if request.sse is not None:
|
|
766
|
+
if request.sse.collect_compact and request.sse.interpret_responses:
|
|
767
|
+
raise ValueError("native compact collection cannot interpret streaming Responses")
|
|
768
|
+
if request.sse.collect_compact and not request.sse.content_type_aware:
|
|
769
|
+
raise ValueError("native compact collection requires content-type-aware framing")
|
|
770
|
+
if not math.isfinite(request.sse.idle_timeout_seconds) or request.sse.idle_timeout_seconds <= 0:
|
|
771
|
+
raise ValueError("native SSE idle_timeout_seconds must be finite and positive")
|
|
772
|
+
if type(request.sse.max_event_bytes) is not int or request.sse.max_event_bytes <= 0:
|
|
773
|
+
raise ValueError("native SSE max_event_bytes must be a positive integer")
|
|
774
|
+
|
|
775
|
+
process, generation = await self._ensure_process()
|
|
776
|
+
self._request_sequence += 1
|
|
777
|
+
request_id = f"{generation}:{self._request_sequence}"
|
|
778
|
+
events: asyncio.Queue[dict[str, object] | BaseException] = _new_stream_queue()
|
|
779
|
+
self._streams[request_id] = (generation, events)
|
|
780
|
+
request_event = {
|
|
781
|
+
"type": "request",
|
|
782
|
+
"request_id": request_id,
|
|
783
|
+
"method": request.method,
|
|
784
|
+
"url": request.url,
|
|
785
|
+
"headers": list(request.headers.items()),
|
|
786
|
+
"body": base64.b64encode(request.body).decode("ascii") if request.body is not None else None,
|
|
787
|
+
"timeout_ms": (
|
|
788
|
+
max(1, round(request.timeout_seconds * 1000)) if request.timeout_seconds is not None else None
|
|
789
|
+
),
|
|
790
|
+
"connect_timeout_ms": (
|
|
791
|
+
max(1, round(request.connect_timeout_seconds * 1000))
|
|
792
|
+
if request.connect_timeout_seconds is not None
|
|
793
|
+
else None
|
|
794
|
+
),
|
|
795
|
+
"proxy_url": request.proxy_url,
|
|
796
|
+
"sse": (
|
|
797
|
+
{
|
|
798
|
+
"idle_timeout_ms": max(1, round(request.sse.idle_timeout_seconds * 1000)),
|
|
799
|
+
"max_event_bytes": request.sse.max_event_bytes,
|
|
800
|
+
"content_type_aware": request.sse.content_type_aware,
|
|
801
|
+
"collect_compact": request.sse.collect_compact,
|
|
802
|
+
"interpret_responses": request.sse.interpret_responses,
|
|
803
|
+
}
|
|
804
|
+
if request.sse is not None
|
|
805
|
+
else None
|
|
806
|
+
),
|
|
807
|
+
}
|
|
808
|
+
try:
|
|
809
|
+
await self._send_command(process, generation, request_event)
|
|
810
|
+
head_timeout = request.response_head_timeout_seconds or request.timeout_seconds
|
|
811
|
+
if request.timeout_seconds is not None and head_timeout is not None:
|
|
812
|
+
head_timeout = min(head_timeout, request.timeout_seconds)
|
|
813
|
+
item = await asyncio.wait_for(events.get(), timeout=head_timeout)
|
|
814
|
+
if isinstance(item, BaseException):
|
|
815
|
+
self._finish_request(request_id, generation, events)
|
|
816
|
+
raise item
|
|
817
|
+
head = item
|
|
818
|
+
if head.get("type") == "error":
|
|
819
|
+
self._finish_request(request_id, generation, events)
|
|
820
|
+
raise _transport_error_from_event(head)
|
|
821
|
+
if head.get("type") != "head":
|
|
822
|
+
raise NativeEgressProtocolError("native response did not begin with a head event")
|
|
823
|
+
status = head.get("status")
|
|
824
|
+
http_version = head.get("http_version")
|
|
825
|
+
raw_headers = head.get("headers")
|
|
826
|
+
if not isinstance(status, int) or not isinstance(http_version, str) or not isinstance(raw_headers, list):
|
|
827
|
+
raise NativeEgressProtocolError("native response head has an invalid shape")
|
|
828
|
+
headers = tuple(_decode_header_pair(pair) for pair in raw_headers)
|
|
829
|
+
content_type = next((value for name, value in headers if name.lower() == "content-type"), "")
|
|
830
|
+
sse_framed = (
|
|
831
|
+
request.sse is not None
|
|
832
|
+
and status < 400
|
|
833
|
+
and (
|
|
834
|
+
not request.sse.content_type_aware
|
|
835
|
+
or not content_type
|
|
836
|
+
or content_type.partition(";")[0].strip().lower() == "text/event-stream"
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
except TimeoutError as exc:
|
|
840
|
+
cancellation = await _await_cleanup_deferring_cancellation(
|
|
841
|
+
self._cancel_request(request_id, generation, events)
|
|
842
|
+
)
|
|
843
|
+
if cancellation is not None:
|
|
844
|
+
raise cancellation
|
|
845
|
+
raise NativeEgressTransportError(
|
|
846
|
+
"native upstream response head timed out",
|
|
847
|
+
failure_phase="timeout",
|
|
848
|
+
) from exc
|
|
849
|
+
except BaseException:
|
|
850
|
+
cancellation = await _await_cleanup_deferring_cancellation(
|
|
851
|
+
self._cancel_request(request_id, generation, events)
|
|
852
|
+
)
|
|
853
|
+
if cancellation is not None:
|
|
854
|
+
raise cancellation
|
|
855
|
+
raise
|
|
856
|
+
|
|
857
|
+
return NativeEgressResponse(
|
|
858
|
+
status=status,
|
|
859
|
+
http_version=http_version,
|
|
860
|
+
headers=headers,
|
|
861
|
+
client=self,
|
|
862
|
+
request_id=request_id,
|
|
863
|
+
generation=generation,
|
|
864
|
+
events=events,
|
|
865
|
+
sse_framed=sse_framed,
|
|
866
|
+
compact_collected=sse_framed and request.sse is not None and request.sse.collect_compact,
|
|
867
|
+
responses_interpreted=sse_framed and request.sse is not None and request.sse.interpret_responses,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
async def websocket(self, request: NativeWebSocketRequest) -> NativeEgressWebSocket:
|
|
871
|
+
if not self.available:
|
|
872
|
+
raise NativeEgressUnavailable(f"native egress helper is unavailable: {self.executable}")
|
|
873
|
+
if request.connect_timeout_seconds <= 0:
|
|
874
|
+
raise ValueError("native websocket connect_timeout_seconds must be positive")
|
|
875
|
+
if request.max_message_bytes <= 0:
|
|
876
|
+
raise ValueError("native websocket max_message_bytes must be positive")
|
|
877
|
+
if request.ping_interval_seconds is not None and request.ping_interval_seconds <= 0:
|
|
878
|
+
raise ValueError("native websocket ping_interval_seconds must be positive")
|
|
879
|
+
if request.ping_timeout_seconds is not None and request.ping_timeout_seconds <= 0:
|
|
880
|
+
raise ValueError("native websocket ping_timeout_seconds must be positive")
|
|
881
|
+
|
|
882
|
+
process, generation = await self._ensure_process()
|
|
883
|
+
self._request_sequence += 1
|
|
884
|
+
request_id = f"{generation}:{self._request_sequence}"
|
|
885
|
+
events: asyncio.Queue[dict[str, object] | BaseException] = _new_stream_queue()
|
|
886
|
+
self._streams[request_id] = (generation, events)
|
|
887
|
+
try:
|
|
888
|
+
await self._send_command(
|
|
889
|
+
process,
|
|
890
|
+
generation,
|
|
891
|
+
{
|
|
892
|
+
"type": "websocket_connect",
|
|
893
|
+
"request_id": request_id,
|
|
894
|
+
"url": request.url,
|
|
895
|
+
"headers": list(request.headers.items()),
|
|
896
|
+
"connect_timeout_ms": max(1, round(request.connect_timeout_seconds * 1000)),
|
|
897
|
+
"max_message_bytes": request.max_message_bytes,
|
|
898
|
+
"ping_interval_ms": (
|
|
899
|
+
max(1, round(request.ping_interval_seconds * 1000))
|
|
900
|
+
if request.ping_interval_seconds is not None
|
|
901
|
+
else None
|
|
902
|
+
),
|
|
903
|
+
"ping_timeout_ms": (
|
|
904
|
+
max(1, round(request.ping_timeout_seconds * 1000))
|
|
905
|
+
if request.ping_timeout_seconds is not None
|
|
906
|
+
else None
|
|
907
|
+
),
|
|
908
|
+
"proxy_url": request.proxy_url,
|
|
909
|
+
"interpret_responses": request.interpret_responses,
|
|
910
|
+
},
|
|
911
|
+
)
|
|
912
|
+
item = await asyncio.wait_for(events.get(), timeout=request.connect_timeout_seconds)
|
|
913
|
+
if isinstance(item, BaseException):
|
|
914
|
+
raise item
|
|
915
|
+
if item.get("type") == "websocket_error":
|
|
916
|
+
raise _websocket_error_from_event(item)
|
|
917
|
+
if item.get("type") != "websocket_open":
|
|
918
|
+
raise NativeEgressProtocolError("native websocket did not begin with an open event")
|
|
919
|
+
status = item.get("status")
|
|
920
|
+
raw_headers = item.get("headers")
|
|
921
|
+
if not isinstance(status, int) or not isinstance(raw_headers, list):
|
|
922
|
+
raise NativeEgressProtocolError("native websocket open event has an invalid shape")
|
|
923
|
+
headers = tuple(_decode_header_pair(pair) for pair in raw_headers)
|
|
924
|
+
except TimeoutError as exc:
|
|
925
|
+
await self._cancel_request(request_id, generation, events)
|
|
926
|
+
raise NativeEgressTransportError(
|
|
927
|
+
"native websocket handshake timed out",
|
|
928
|
+
failure_phase="connect",
|
|
929
|
+
retryable_same_contract=True,
|
|
930
|
+
) from exc
|
|
931
|
+
except BaseException:
|
|
932
|
+
await self._cancel_request(request_id, generation, events)
|
|
933
|
+
raise
|
|
934
|
+
|
|
935
|
+
return NativeEgressWebSocket(
|
|
936
|
+
status=status,
|
|
937
|
+
headers=headers,
|
|
938
|
+
client=self,
|
|
939
|
+
process=process,
|
|
940
|
+
request_id=request_id,
|
|
941
|
+
generation=generation,
|
|
942
|
+
events=events,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
async def aclose(self) -> None:
|
|
946
|
+
"""Stop the current helper generation and fail any remaining streams."""
|
|
947
|
+
|
|
948
|
+
async with self._start_lock:
|
|
949
|
+
if self._closed:
|
|
950
|
+
return
|
|
951
|
+
self._closed = True
|
|
952
|
+
process = self._process
|
|
953
|
+
reader_task = self._reader_task
|
|
954
|
+
generation = self._generation
|
|
955
|
+
if process is not None and process.stdin is not None:
|
|
956
|
+
process.stdin.close()
|
|
957
|
+
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
|
958
|
+
await process.stdin.wait_closed()
|
|
959
|
+
if process is not None:
|
|
960
|
+
try:
|
|
961
|
+
await asyncio.wait_for(process.wait(), timeout=_NATIVE_CANCEL_TIMEOUT_SECONDS)
|
|
962
|
+
except TimeoutError:
|
|
963
|
+
await _stop_process(process)
|
|
964
|
+
if reader_task is not None and reader_task is not asyncio.current_task():
|
|
965
|
+
if not reader_task.done():
|
|
966
|
+
reader_task.cancel()
|
|
967
|
+
with contextlib.suppress(asyncio.CancelledError, NativeEgressError):
|
|
968
|
+
await reader_task
|
|
969
|
+
self._fail_generation(
|
|
970
|
+
generation,
|
|
971
|
+
NativeEgressTransportError("native helper closed", failure_phase="shutdown"),
|
|
972
|
+
)
|
|
973
|
+
self._process = None
|
|
974
|
+
self._reader_task = None
|
|
975
|
+
|
|
976
|
+
async def _ensure_process(self) -> tuple[asyncio.subprocess.Process, int]:
|
|
977
|
+
async with self._start_lock:
|
|
978
|
+
if self._closed:
|
|
979
|
+
raise NativeEgressUnavailable("native egress client is closed")
|
|
980
|
+
if self._process is not None and self._process.returncode is None:
|
|
981
|
+
return self._process, self._generation
|
|
982
|
+
try:
|
|
983
|
+
process = await asyncio.create_subprocess_exec(
|
|
984
|
+
str(self.executable),
|
|
985
|
+
stdin=asyncio.subprocess.PIPE,
|
|
986
|
+
stdout=asyncio.subprocess.PIPE,
|
|
987
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
988
|
+
limit=_NATIVE_EVENT_LINE_LIMIT,
|
|
989
|
+
)
|
|
990
|
+
except OSError as exc:
|
|
991
|
+
raise NativeEgressUnavailable(f"native egress helper could not start: {self.executable}") from exc
|
|
992
|
+
if process.stdin is None or process.stdout is None:
|
|
993
|
+
await _stop_process(process)
|
|
994
|
+
raise NativeEgressUnavailable("native helper pipes are unavailable")
|
|
995
|
+
try:
|
|
996
|
+
await self._negotiate_process(process)
|
|
997
|
+
except BaseException:
|
|
998
|
+
await _stop_process(process)
|
|
999
|
+
raise
|
|
1000
|
+
self._generation += 1
|
|
1001
|
+
generation = self._generation
|
|
1002
|
+
self._process = process
|
|
1003
|
+
self._reader_task = asyncio.create_task(
|
|
1004
|
+
self._read_process(process, generation),
|
|
1005
|
+
name=f"native-egress-reader-{generation}",
|
|
1006
|
+
)
|
|
1007
|
+
return process, generation
|
|
1008
|
+
|
|
1009
|
+
async def _negotiate_process(self, process: asyncio.subprocess.Process) -> None:
|
|
1010
|
+
"""Fail closed before dispatch when the installed worker is incompatible."""
|
|
1011
|
+
|
|
1012
|
+
stdin = process.stdin
|
|
1013
|
+
stdout = process.stdout
|
|
1014
|
+
if stdin is None or stdout is None:
|
|
1015
|
+
raise NativeEgressProtocolError("native helper handshake pipes are unavailable")
|
|
1016
|
+
hello = {
|
|
1017
|
+
"type": "client_hello",
|
|
1018
|
+
"min_protocol_version": _NATIVE_PROTOCOL_VERSION,
|
|
1019
|
+
"max_protocol_version": _NATIVE_PROTOCOL_VERSION,
|
|
1020
|
+
}
|
|
1021
|
+
try:
|
|
1022
|
+
stdin.write(json.dumps(hello, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
1023
|
+
await stdin.drain()
|
|
1024
|
+
event = await asyncio.wait_for(
|
|
1025
|
+
_read_event(stdout),
|
|
1026
|
+
timeout=_NATIVE_PROTOCOL_HANDSHAKE_TIMEOUT_SECONDS,
|
|
1027
|
+
)
|
|
1028
|
+
except TimeoutError as exc:
|
|
1029
|
+
raise NativeEgressProtocolError("native helper protocol handshake timed out") from exc
|
|
1030
|
+
except (BrokenPipeError, ConnectionResetError) as exc:
|
|
1031
|
+
raise NativeEgressProtocolError("native helper closed during protocol handshake") from exc
|
|
1032
|
+
|
|
1033
|
+
if event.get("type") != "server_hello":
|
|
1034
|
+
raise NativeEgressProtocolError("native helper did not acknowledge the protocol handshake")
|
|
1035
|
+
version = event.get("protocol_version")
|
|
1036
|
+
if version != _NATIVE_PROTOCOL_VERSION:
|
|
1037
|
+
raise NativeEgressProtocolError(f"native helper selected unsupported protocol version: {version!r}")
|
|
1038
|
+
capabilities = event.get("capabilities")
|
|
1039
|
+
if not isinstance(capabilities, list) or not all(isinstance(value, str) for value in capabilities):
|
|
1040
|
+
raise NativeEgressProtocolError("native helper capabilities have an invalid shape")
|
|
1041
|
+
missing = sorted(_REQUIRED_NATIVE_CAPABILITIES.difference(capabilities))
|
|
1042
|
+
if missing:
|
|
1043
|
+
raise NativeEgressProtocolError(f"native helper is missing required capabilities: {', '.join(missing)}")
|
|
1044
|
+
|
|
1045
|
+
async def _send_command(
|
|
1046
|
+
self,
|
|
1047
|
+
process: asyncio.subprocess.Process,
|
|
1048
|
+
generation: int,
|
|
1049
|
+
command: Mapping[str, object],
|
|
1050
|
+
) -> None:
|
|
1051
|
+
async with self._write_lock:
|
|
1052
|
+
if process is not self._process or generation != self._generation or process.returncode is not None:
|
|
1053
|
+
raise NativeEgressTransportError("native helper exited before command dispatch")
|
|
1054
|
+
stdin = process.stdin
|
|
1055
|
+
if stdin is None:
|
|
1056
|
+
raise NativeEgressTransportError("native helper stdin is unavailable")
|
|
1057
|
+
try:
|
|
1058
|
+
stdin.write(json.dumps(command, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
1059
|
+
await stdin.drain()
|
|
1060
|
+
except (BrokenPipeError, ConnectionResetError) as exc:
|
|
1061
|
+
raise NativeEgressTransportError("native helper closed during command dispatch") from exc
|
|
1062
|
+
|
|
1063
|
+
async def _read_process(self, process: asyncio.subprocess.Process, generation: int) -> None:
|
|
1064
|
+
stdout = process.stdout
|
|
1065
|
+
if stdout is None:
|
|
1066
|
+
self._fail_generation(generation, NativeEgressProtocolError("native helper stdout is unavailable"))
|
|
1067
|
+
return
|
|
1068
|
+
failure: NativeEgressError = NativeEgressTransportError(
|
|
1069
|
+
"native helper exited with active requests",
|
|
1070
|
+
failure_phase="helper_exit",
|
|
1071
|
+
)
|
|
1072
|
+
try:
|
|
1073
|
+
while True:
|
|
1074
|
+
event = await _read_event(stdout)
|
|
1075
|
+
request_id = event.get("request_id")
|
|
1076
|
+
if not isinstance(request_id, str):
|
|
1077
|
+
raise NativeEgressProtocolError("native helper event is missing request_id")
|
|
1078
|
+
state = self._streams.get(request_id)
|
|
1079
|
+
if state is None or state[0] != generation:
|
|
1080
|
+
continue
|
|
1081
|
+
events = state[1]
|
|
1082
|
+
try:
|
|
1083
|
+
events.put_nowait(event)
|
|
1084
|
+
# readline() need not suspend for buffered helper output.
|
|
1085
|
+
# Let ready consumers drain before dispatching another event;
|
|
1086
|
+
# a stalled consumer still hits the bounded queue.
|
|
1087
|
+
await asyncio.sleep(0)
|
|
1088
|
+
except asyncio.QueueFull:
|
|
1089
|
+
overflow_failure = NativeEgressTransportError(
|
|
1090
|
+
"native stream consumer exceeded the bounded event queue",
|
|
1091
|
+
failure_phase="consumer_backpressure",
|
|
1092
|
+
)
|
|
1093
|
+
self._finish_request(request_id, generation, events)
|
|
1094
|
+
while not events.empty():
|
|
1095
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
1096
|
+
events.get_nowait()
|
|
1097
|
+
events.put_nowait(overflow_failure)
|
|
1098
|
+
asyncio.create_task(
|
|
1099
|
+
self._cancel_orphaned_request(process, generation, request_id),
|
|
1100
|
+
name=f"native-egress-overflow-cancel-{request_id}",
|
|
1101
|
+
)
|
|
1102
|
+
except NativeEgressProtocolError as exc:
|
|
1103
|
+
failure = exc
|
|
1104
|
+
except BaseException as exc:
|
|
1105
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
1106
|
+
raise
|
|
1107
|
+
failure = NativeEgressTransportError(
|
|
1108
|
+
"native helper reader failed",
|
|
1109
|
+
failure_phase="helper_read",
|
|
1110
|
+
)
|
|
1111
|
+
finally:
|
|
1112
|
+
self._fail_generation(generation, failure)
|
|
1113
|
+
if process.returncode is None:
|
|
1114
|
+
await _stop_process(process)
|
|
1115
|
+
|
|
1116
|
+
async def _cancel_request(
|
|
1117
|
+
self,
|
|
1118
|
+
request_id: str,
|
|
1119
|
+
generation: int,
|
|
1120
|
+
events: asyncio.Queue[dict[str, object] | BaseException],
|
|
1121
|
+
) -> None:
|
|
1122
|
+
state = self._streams.get(request_id)
|
|
1123
|
+
if state != (generation, events):
|
|
1124
|
+
return
|
|
1125
|
+
process = self._process
|
|
1126
|
+
if process is not None and generation == self._generation and process.returncode is None:
|
|
1127
|
+
try:
|
|
1128
|
+
await self._send_command(
|
|
1129
|
+
process,
|
|
1130
|
+
generation,
|
|
1131
|
+
{"type": "cancel", "request_id": request_id},
|
|
1132
|
+
)
|
|
1133
|
+
while True:
|
|
1134
|
+
item = await asyncio.wait_for(events.get(), timeout=_NATIVE_CANCEL_TIMEOUT_SECONDS)
|
|
1135
|
+
if isinstance(item, BaseException) or item.get("type") in {
|
|
1136
|
+
"cancelled",
|
|
1137
|
+
"end",
|
|
1138
|
+
"error",
|
|
1139
|
+
"sse_event_too_large",
|
|
1140
|
+
}:
|
|
1141
|
+
break
|
|
1142
|
+
except (TimeoutError, NativeEgressError):
|
|
1143
|
+
pass
|
|
1144
|
+
self._finish_request(request_id, generation, events)
|
|
1145
|
+
|
|
1146
|
+
async def _cancel_orphaned_request(
|
|
1147
|
+
self,
|
|
1148
|
+
process: asyncio.subprocess.Process,
|
|
1149
|
+
generation: int,
|
|
1150
|
+
request_id: str,
|
|
1151
|
+
) -> None:
|
|
1152
|
+
with contextlib.suppress(NativeEgressError):
|
|
1153
|
+
await self._send_command(
|
|
1154
|
+
process,
|
|
1155
|
+
generation,
|
|
1156
|
+
{"type": "cancel", "request_id": request_id},
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
async def _abort_request(
|
|
1160
|
+
self,
|
|
1161
|
+
request_id: str,
|
|
1162
|
+
generation: int,
|
|
1163
|
+
events: asyncio.Queue[dict[str, object] | BaseException],
|
|
1164
|
+
) -> None:
|
|
1165
|
+
"""Best-effort cancel when the stream protocol itself is unusable."""
|
|
1166
|
+
|
|
1167
|
+
state = self._streams.get(request_id)
|
|
1168
|
+
if state != (generation, events):
|
|
1169
|
+
return
|
|
1170
|
+
process = self._process
|
|
1171
|
+
if process is not None and generation == self._generation and process.returncode is None:
|
|
1172
|
+
with contextlib.suppress(NativeEgressError):
|
|
1173
|
+
await self._send_command(
|
|
1174
|
+
process,
|
|
1175
|
+
generation,
|
|
1176
|
+
{"type": "cancel", "request_id": request_id},
|
|
1177
|
+
)
|
|
1178
|
+
self._finish_request(request_id, generation, events)
|
|
1179
|
+
|
|
1180
|
+
def _finish_request(
|
|
1181
|
+
self,
|
|
1182
|
+
request_id: str,
|
|
1183
|
+
generation: int,
|
|
1184
|
+
events: asyncio.Queue[dict[str, object] | BaseException],
|
|
1185
|
+
) -> None:
|
|
1186
|
+
if self._streams.get(request_id) == (generation, events):
|
|
1187
|
+
self._streams.pop(request_id, None)
|
|
1188
|
+
|
|
1189
|
+
def _fail_generation(self, generation: int, failure: BaseException) -> None:
|
|
1190
|
+
if self._generation == generation:
|
|
1191
|
+
self._process = None
|
|
1192
|
+
self._reader_task = None
|
|
1193
|
+
failed = [
|
|
1194
|
+
(request_id, events)
|
|
1195
|
+
for request_id, (stream_generation, events) in self._streams.items()
|
|
1196
|
+
if stream_generation == generation
|
|
1197
|
+
]
|
|
1198
|
+
for request_id, events in failed:
|
|
1199
|
+
self._streams.pop(request_id, None)
|
|
1200
|
+
while not events.empty():
|
|
1201
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
1202
|
+
events.get_nowait()
|
|
1203
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
1204
|
+
events.put_nowait(failure)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
@lru_cache(maxsize=1)
|
|
1208
|
+
def discover_native_egress_client() -> SubprocessNativeEgressClient | None:
|
|
1209
|
+
"""Resolve only the fixed packaged helper from the runtime PATH."""
|
|
1210
|
+
|
|
1211
|
+
executable = shutil.which(_NATIVE_EGRESS_EXECUTABLE)
|
|
1212
|
+
if executable is None:
|
|
1213
|
+
return None
|
|
1214
|
+
client = SubprocessNativeEgressClient(executable)
|
|
1215
|
+
if not client.available:
|
|
1216
|
+
return None
|
|
1217
|
+
logger.info("Native Codex HTTP egress enabled executable=%s", executable)
|
|
1218
|
+
return client
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
async def close_discovered_native_egress_client() -> None:
|
|
1222
|
+
"""Close the cached helper generation, then allow clean rediscovery."""
|
|
1223
|
+
|
|
1224
|
+
client = discover_native_egress_client()
|
|
1225
|
+
if client is not None:
|
|
1226
|
+
await client.aclose()
|
|
1227
|
+
discover_native_egress_client.cache_clear()
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _decode_header_pair(value: object) -> tuple[str, str]:
|
|
1231
|
+
if not isinstance(value, list) or len(value) != 2 or not all(isinstance(item, str) for item in value):
|
|
1232
|
+
raise NativeEgressProtocolError("native response contains an invalid header pair")
|
|
1233
|
+
pair = cast(list[str], value)
|
|
1234
|
+
return pair[0], pair[1]
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _transport_error_from_event(event: Mapping[str, object]) -> NativeEgressTransportError:
|
|
1238
|
+
message = event.get("message")
|
|
1239
|
+
failure_phase = event.get("failure_phase")
|
|
1240
|
+
retryable_same_contract = event.get("retryable_same_contract")
|
|
1241
|
+
is_tls_verification_failure = event.get("is_tls_verification_failure")
|
|
1242
|
+
return NativeEgressTransportError(
|
|
1243
|
+
message if isinstance(message, str) else "native request failed",
|
|
1244
|
+
failure_phase=failure_phase if isinstance(failure_phase, str) else "request",
|
|
1245
|
+
retryable_same_contract=retryable_same_contract if isinstance(retryable_same_contract, bool) else False,
|
|
1246
|
+
is_tls_verification_failure=(
|
|
1247
|
+
is_tls_verification_failure if isinstance(is_tls_verification_failure, bool) else False
|
|
1248
|
+
),
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _websocket_error_from_event(event: Mapping[str, object]) -> NativeEgressTransportError:
|
|
1253
|
+
message = event.get("message")
|
|
1254
|
+
failure_phase = event.get("failure_phase")
|
|
1255
|
+
retryable_same_contract = event.get("retryable_same_contract")
|
|
1256
|
+
is_tls_verification_failure = event.get("is_tls_verification_failure")
|
|
1257
|
+
status = event.get("status")
|
|
1258
|
+
raw_headers = event.get("headers")
|
|
1259
|
+
encoded_body = event.get("body")
|
|
1260
|
+
headers: tuple[tuple[str, str], ...] = ()
|
|
1261
|
+
if isinstance(raw_headers, list):
|
|
1262
|
+
headers = tuple(_decode_header_pair(pair) for pair in raw_headers)
|
|
1263
|
+
body: bytes | None = None
|
|
1264
|
+
if isinstance(encoded_body, str):
|
|
1265
|
+
try:
|
|
1266
|
+
body = base64.b64decode(encoded_body, validate=True)
|
|
1267
|
+
except ValueError as exc:
|
|
1268
|
+
raise NativeEgressProtocolError("native websocket error body is not base64") from exc
|
|
1269
|
+
return NativeEgressTransportError(
|
|
1270
|
+
message if isinstance(message, str) else "native websocket failed",
|
|
1271
|
+
failure_phase=failure_phase if isinstance(failure_phase, str) else "websocket",
|
|
1272
|
+
retryable_same_contract=retryable_same_contract if isinstance(retryable_same_contract, bool) else False,
|
|
1273
|
+
is_tls_verification_failure=(
|
|
1274
|
+
is_tls_verification_failure if isinstance(is_tls_verification_failure, bool) else False
|
|
1275
|
+
),
|
|
1276
|
+
status_code=status if isinstance(status, int) else None,
|
|
1277
|
+
headers=headers,
|
|
1278
|
+
body=body,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
async def _read_event(stdout: asyncio.StreamReader) -> dict[str, object]:
|
|
1283
|
+
line = await stdout.readline()
|
|
1284
|
+
if not line:
|
|
1285
|
+
raise NativeEgressProtocolError("native helper closed its stream unexpectedly")
|
|
1286
|
+
try:
|
|
1287
|
+
value = json.loads(line)
|
|
1288
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
1289
|
+
raise NativeEgressProtocolError("native helper emitted invalid JSON") from exc
|
|
1290
|
+
if not isinstance(value, dict):
|
|
1291
|
+
raise NativeEgressProtocolError("native helper event must be a JSON object")
|
|
1292
|
+
return cast(dict[str, object], value)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
|
1296
|
+
if process.returncode is not None:
|
|
1297
|
+
return
|
|
1298
|
+
with contextlib.suppress(ProcessLookupError):
|
|
1299
|
+
process.terminate()
|
|
1300
|
+
try:
|
|
1301
|
+
await asyncio.wait_for(process.wait(), timeout=1.0)
|
|
1302
|
+
except TimeoutError:
|
|
1303
|
+
process.kill()
|
|
1304
|
+
await process.wait()
|