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,1612 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Collection, Iterable, Literal
|
|
11
|
+
|
|
12
|
+
from app.core.balancer.types import FailureClass, UpstreamError
|
|
13
|
+
from app.core.usage import PLAN_CAPACITY_CREDITS_SECONDARY
|
|
14
|
+
from app.core.utils.retry import backoff_seconds, parse_retry_after
|
|
15
|
+
from app.db.models import AccountStatus
|
|
16
|
+
|
|
17
|
+
PERMANENT_FAILURE_CODES = {
|
|
18
|
+
"refresh_token_expired": "Refresh token expired - re-login required",
|
|
19
|
+
"refresh_token_reused": "Refresh token was reused - re-login required",
|
|
20
|
+
"refresh_token_invalidated": "Refresh token was revoked - re-login required",
|
|
21
|
+
"invalid_grant": "Refresh token grant invalid - re-login required",
|
|
22
|
+
"token_invalidated": "Authentication token invalidated - re-login required",
|
|
23
|
+
# ``token_expired`` from the OAuth refresh endpoint means the refresh
|
|
24
|
+
# request itself failed because the refresh token (or the session it
|
|
25
|
+
# belonged to) is no longer usable -- access-token-only expiry would have
|
|
26
|
+
# returned a fresh token pair instead. Treat it as a permanent refresh
|
|
27
|
+
# failure while ordinary requests may continue with the stored access token.
|
|
28
|
+
"token_expired": "Authentication token expired - re-login required",
|
|
29
|
+
"app_session_terminated": "ChatGPT session ended - re-login required",
|
|
30
|
+
"account_session_expired": "ChatGPT session ended - re-login required",
|
|
31
|
+
"account_auth_invalidated": "Authentication failed after token refresh - re-login required",
|
|
32
|
+
# The OAuth endpoint uses this code for an invalid/revoked refresh token.
|
|
33
|
+
# Keep it in the permanent set so the account is surfaced for re-auth
|
|
34
|
+
# instead of remaining active while every request retries the dead token.
|
|
35
|
+
"invalid_refresh_token": "Refresh token invalid - re-login required",
|
|
36
|
+
"account_deactivated": "Account has been deactivated",
|
|
37
|
+
"deactivated_workspace": "Workspace has been deactivated",
|
|
38
|
+
"account_suspended": "Account has been suspended",
|
|
39
|
+
"account_deleted": "Account has been deleted",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
REAUTH_REQUIRED_FAILURE_CODES = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"refresh_token_expired",
|
|
45
|
+
"refresh_token_reused",
|
|
46
|
+
"refresh_token_invalidated",
|
|
47
|
+
"invalid_grant",
|
|
48
|
+
"token_invalidated",
|
|
49
|
+
"token_expired",
|
|
50
|
+
"app_session_terminated",
|
|
51
|
+
"account_session_expired",
|
|
52
|
+
"account_auth_invalidated",
|
|
53
|
+
"invalid_refresh_token",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
SECONDS_PER_DAY = 60 * 60 * 24
|
|
58
|
+
SECONDS_PER_HOUR = 60 * 60
|
|
59
|
+
SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY
|
|
60
|
+
RATE_LIMIT_RESET_MAX_HORIZON_SECONDS = 366 * SECONDS_PER_DAY
|
|
61
|
+
RATE_LIMIT_RESET_ROUNDING_TOLERANCE_SECONDS = 1.0
|
|
62
|
+
UNKNOWN_RESET_BUCKET_DAYS = 10_000
|
|
63
|
+
UNKNOWN_RESET_FALLBACK_SECONDS = 7 * SECONDS_PER_DAY
|
|
64
|
+
RELATIVE_AVAILABILITY_MIN_DIVISOR_SECONDS = 5 * 60
|
|
65
|
+
RELATIVE_AVAILABILITY_MIN_WEIGHT_FRACTION = 0.1
|
|
66
|
+
DEFAULT_RELATIVE_AVAILABILITY_POWER = 2.0
|
|
67
|
+
DEFAULT_RELATIVE_AVAILABILITY_TOP_K = 5
|
|
68
|
+
RoutingStrategy = Literal[
|
|
69
|
+
"usage_weighted",
|
|
70
|
+
"round_robin",
|
|
71
|
+
"capacity_weighted",
|
|
72
|
+
"relative_availability",
|
|
73
|
+
"fill_first",
|
|
74
|
+
"sequential_drain",
|
|
75
|
+
"reset_drain",
|
|
76
|
+
"single_account",
|
|
77
|
+
]
|
|
78
|
+
TrafficClass = Literal["foreground", "opportunistic"]
|
|
79
|
+
UsageWeightedOrder = Literal["secondary_first", "primary_first"]
|
|
80
|
+
ResetPreferenceWindow = Literal["primary", "secondary"]
|
|
81
|
+
UNKNOWN_PLAN_FALLBACK = "free"
|
|
82
|
+
CAPACITY_PLAN_ALIASES = {
|
|
83
|
+
"education": "edu",
|
|
84
|
+
"k12": "edu",
|
|
85
|
+
"guest": "free",
|
|
86
|
+
"go": "free",
|
|
87
|
+
"free_workspace": "free",
|
|
88
|
+
"quorum": "free",
|
|
89
|
+
"unknown": "free",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
HEALTH_TIER_HEALTHY = 0
|
|
93
|
+
HEALTH_TIER_DRAINING = 1
|
|
94
|
+
HEALTH_TIER_PROBING = 2
|
|
95
|
+
|
|
96
|
+
DRAIN_PRIMARY_THRESHOLD_PCT = 85.0
|
|
97
|
+
DRAIN_SECONDARY_THRESHOLD_PCT = 90.0
|
|
98
|
+
DRAIN_ERROR_WINDOW_SECONDS = 60.0
|
|
99
|
+
DRAIN_ERROR_COUNT_THRESHOLD = 2
|
|
100
|
+
ERROR_BACKOFF_THRESHOLD = 3
|
|
101
|
+
PROBE_QUIET_SECONDS = 60.0
|
|
102
|
+
PROBE_SUCCESS_STREAK_REQUIRED = 3
|
|
103
|
+
ROUTING_POLICY_NORMAL = "normal"
|
|
104
|
+
ROUTING_POLICY_BURN_FIRST = "burn_first"
|
|
105
|
+
ROUTING_POLICY_PRESERVE = "preserve"
|
|
106
|
+
TRAFFIC_CLASS_FOREGROUND = "foreground"
|
|
107
|
+
TRAFFIC_CLASS_OPPORTUNISTIC = "opportunistic"
|
|
108
|
+
PRESERVE_MIN_WEEKLY_FLOOR_PCT = 5.0
|
|
109
|
+
PRESERVE_MIN_SHORT_WINDOW_FLOOR_PCT = 10.0
|
|
110
|
+
NORMAL_LAST_ACCOUNT_EMERGENCY_FLOOR_PCT = 5.0
|
|
111
|
+
RECENT_FOREGROUND_ACTIVITY_SECONDS = 30 * 60
|
|
112
|
+
|
|
113
|
+
logger = logging.getLogger(__name__)
|
|
114
|
+
|
|
115
|
+
_RELATIVE_AVAILABILITY_LOG_PREFIX_CANDIDATE = "Relative availability candidate "
|
|
116
|
+
_RELATIVE_AVAILABILITY_LOG_PREFIX_TOP_K = "Relative availability top-k "
|
|
117
|
+
_RELATIVE_AVAILABILITY_LOG_PREFIX_WINNER = "Relative availability winner "
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class AccountState:
|
|
122
|
+
account_id: str
|
|
123
|
+
status: AccountStatus
|
|
124
|
+
used_percent: float | None = None
|
|
125
|
+
reset_at: float | None = None
|
|
126
|
+
primary_reset_at: int | None = None
|
|
127
|
+
primary_window_minutes: int | None = None
|
|
128
|
+
blocked_at: float | None = None
|
|
129
|
+
cooldown_until: float | None = None
|
|
130
|
+
secondary_used_percent: float | None = None
|
|
131
|
+
secondary_reset_at: int | None = None
|
|
132
|
+
last_error_at: float | None = None
|
|
133
|
+
last_selected_at: float | None = None
|
|
134
|
+
error_count: int = 0
|
|
135
|
+
deactivation_reason: str | None = None
|
|
136
|
+
plan_type: str | None = None
|
|
137
|
+
capacity_credits: float | None = None
|
|
138
|
+
health_tier: int = 0
|
|
139
|
+
priority_used_percent: float | None = None
|
|
140
|
+
priority_secondary_used_percent: float | None = None
|
|
141
|
+
priority_reset_at: int | None = None
|
|
142
|
+
priority_capacity_credits: float | None = None
|
|
143
|
+
limit_scoped_usage: bool = False
|
|
144
|
+
access_token_expires_at: float | None = None
|
|
145
|
+
inflight_response_creates: int = 0
|
|
146
|
+
inflight_streams: int = 0
|
|
147
|
+
leased_tokens: float = 0.0
|
|
148
|
+
routing_policy: str = ROUTING_POLICY_NORMAL
|
|
149
|
+
ignore_standard_quota: bool = False
|
|
150
|
+
# Multiplier applied to this candidate's draw weight by the weighted
|
|
151
|
+
# strategies (``capacity_weighted``, ``relative_availability``); ``1.0`` is
|
|
152
|
+
# neutral. The balancer derives it from the account's recent upstream
|
|
153
|
+
# error rate. Deterministic strategies ignore it.
|
|
154
|
+
selection_weight_multiplier: float = 1.0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class SelectionResult:
|
|
159
|
+
account: AccountState | None
|
|
160
|
+
error_message: str | None
|
|
161
|
+
error_code: str | None = None
|
|
162
|
+
resets_at: int | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
USAGE_LIMIT_REACHED = "usage_limit_reached"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def pool_usage_exhaustion(
|
|
169
|
+
states: Iterable[AccountState],
|
|
170
|
+
*,
|
|
171
|
+
current: float,
|
|
172
|
+
ignore_standard_quota: bool = False,
|
|
173
|
+
ignore_standard_quota_account_ids: Collection[str] | None = None,
|
|
174
|
+
) -> SelectionResult | None:
|
|
175
|
+
"""Describe pool-wide subscription exhaustion without parsing retry text."""
|
|
176
|
+
if ignore_standard_quota:
|
|
177
|
+
return None
|
|
178
|
+
ignored_account_ids = set(ignore_standard_quota_account_ids or ())
|
|
179
|
+
|
|
180
|
+
def _primary_usage_evidence(state: AccountState) -> float | None:
|
|
181
|
+
return state.priority_used_percent if state.priority_used_percent is not None else state.used_percent
|
|
182
|
+
|
|
183
|
+
def _secondary_usage_evidence(state: AccountState) -> float | None:
|
|
184
|
+
if state.limit_scoped_usage and state.priority_secondary_used_percent is None:
|
|
185
|
+
return _primary_usage_evidence(state)
|
|
186
|
+
return (
|
|
187
|
+
state.priority_secondary_used_percent
|
|
188
|
+
if state.priority_secondary_used_percent is not None
|
|
189
|
+
else state.secondary_used_percent
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _usage_exhausted(state: AccountState) -> bool:
|
|
193
|
+
if state.status not in (AccountStatus.QUOTA_EXCEEDED, AccountStatus.RATE_LIMITED):
|
|
194
|
+
return False
|
|
195
|
+
usage_values = (_primary_usage_evidence(state), _secondary_usage_evidence(state))
|
|
196
|
+
return any(value is not None and float(value) >= 100.0 for value in usage_values)
|
|
197
|
+
|
|
198
|
+
def _usage_exhausted_reset_at(state: AccountState) -> float | None:
|
|
199
|
+
if state.status not in (AccountStatus.QUOTA_EXCEEDED, AccountStatus.RATE_LIMITED):
|
|
200
|
+
return None
|
|
201
|
+
candidates: list[float] = []
|
|
202
|
+
primary_evidence = _primary_usage_evidence(state)
|
|
203
|
+
secondary_evidence = _secondary_usage_evidence(state)
|
|
204
|
+
if primary_evidence is not None and float(primary_evidence) >= 100.0 and state.primary_reset_at is not None:
|
|
205
|
+
candidates.append(float(state.primary_reset_at))
|
|
206
|
+
if (
|
|
207
|
+
secondary_evidence is not None
|
|
208
|
+
and float(secondary_evidence) >= 100.0
|
|
209
|
+
and state.secondary_reset_at is not None
|
|
210
|
+
):
|
|
211
|
+
candidates.append(float(state.secondary_reset_at))
|
|
212
|
+
if not candidates:
|
|
213
|
+
return None
|
|
214
|
+
return max(candidates)
|
|
215
|
+
|
|
216
|
+
eligible = [
|
|
217
|
+
state
|
|
218
|
+
for state in states
|
|
219
|
+
if not state.ignore_standard_quota
|
|
220
|
+
and state.account_id not in ignored_account_ids
|
|
221
|
+
and state.status
|
|
222
|
+
not in (
|
|
223
|
+
AccountStatus.PAUSED,
|
|
224
|
+
AccountStatus.DEACTIVATED,
|
|
225
|
+
)
|
|
226
|
+
]
|
|
227
|
+
if not eligible or any(not _usage_exhausted(state) for state in eligible):
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# Only usage-proven exhausted accounts reach this branch; surface the
|
|
231
|
+
# earliest reset for an actually exhausted window so the structured 429
|
|
232
|
+
# does not retry a secondary-window exhaustion at the primary reset time.
|
|
233
|
+
reset_candidates = [reset_at for state in eligible if (reset_at := _usage_exhausted_reset_at(state)) is not None]
|
|
234
|
+
resets_at = int(min(reset_candidates)) if reset_candidates else None
|
|
235
|
+
message = "Usage limit reached"
|
|
236
|
+
if resets_at is not None:
|
|
237
|
+
message = _format_retry_hint(max(0.0, resets_at - current))
|
|
238
|
+
return SelectionResult(
|
|
239
|
+
account=None,
|
|
240
|
+
error_message=message,
|
|
241
|
+
error_code=USAGE_LIMIT_REACHED,
|
|
242
|
+
resets_at=resets_at,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True)
|
|
247
|
+
class RoutingCost:
|
|
248
|
+
"""Request-scoped planner cost applied after hard eligibility filters."""
|
|
249
|
+
|
|
250
|
+
total: float = 0.0
|
|
251
|
+
reason: str | None = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
RoutingCostsByAccount = dict[str, RoutingCost]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _planner_cost(state: AccountState, routing_costs: RoutingCostsByAccount | None) -> float:
|
|
258
|
+
if not routing_costs:
|
|
259
|
+
return 0.0
|
|
260
|
+
cost = routing_costs.get(state.account_id)
|
|
261
|
+
if cost is None:
|
|
262
|
+
return 0.0
|
|
263
|
+
return float(cost.total)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _usage_sort_key(state: AccountState) -> tuple[float, float, float, str]:
|
|
267
|
+
primary_used = _priority_primary_used(state)
|
|
268
|
+
secondary_used = _priority_secondary_used(state, primary_used)
|
|
269
|
+
last_selected = state.last_selected_at or 0.0
|
|
270
|
+
return secondary_used, primary_used, last_selected, state.account_id
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _primary_usage_sort_key(state: AccountState) -> tuple[float, float, float, str]:
|
|
274
|
+
primary_used = _priority_primary_used(state)
|
|
275
|
+
secondary_used = _priority_secondary_used(state, primary_used)
|
|
276
|
+
last_selected = state.last_selected_at or 0.0
|
|
277
|
+
return primary_used, secondary_used, last_selected, state.account_id
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _routing_policy(state: AccountState) -> str:
|
|
281
|
+
if state.routing_policy in {
|
|
282
|
+
ROUTING_POLICY_BURN_FIRST,
|
|
283
|
+
ROUTING_POLICY_NORMAL,
|
|
284
|
+
ROUTING_POLICY_PRESERVE,
|
|
285
|
+
}:
|
|
286
|
+
return state.routing_policy
|
|
287
|
+
return ROUTING_POLICY_NORMAL
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _used_pct(state: AccountState, *, secondary: bool) -> float | None:
|
|
291
|
+
if secondary:
|
|
292
|
+
return state.secondary_used_percent if state.secondary_used_percent is not None else state.used_percent
|
|
293
|
+
return state.used_percent
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _remaining_pct(state: AccountState, *, secondary: bool) -> float | None:
|
|
297
|
+
used_pct = _used_pct(state, secondary=secondary)
|
|
298
|
+
if used_pct is None:
|
|
299
|
+
return None
|
|
300
|
+
return max(0.0, 100.0 - min(100.0, used_pct))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _seconds_until(reset_at: int | float | None, current: float) -> float | None:
|
|
304
|
+
if reset_at is None:
|
|
305
|
+
return None
|
|
306
|
+
return max(0.0, float(reset_at) - current)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _recent_foreground_activity(state: AccountState, current: float) -> bool:
|
|
310
|
+
return state.last_selected_at is not None and current - state.last_selected_at <= RECENT_FOREGROUND_ACTIVITY_SECONDS
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _weekly_pace_floor_pct(state: AccountState, current: float) -> float:
|
|
314
|
+
remaining_seconds = _seconds_until(state.secondary_reset_at, current)
|
|
315
|
+
used_pct = _used_pct(state, secondary=True)
|
|
316
|
+
if remaining_seconds is None or used_pct is None:
|
|
317
|
+
return 100.0
|
|
318
|
+
|
|
319
|
+
elapsed_seconds = max(0.0, SECONDS_PER_WEEK - remaining_seconds)
|
|
320
|
+
expected_used_pct = min(100.0, (elapsed_seconds / SECONDS_PER_WEEK) * 100.0)
|
|
321
|
+
behind_pace = used_pct + 5.0 < expected_used_pct
|
|
322
|
+
|
|
323
|
+
if remaining_seconds <= 6 * SECONDS_PER_HOUR and behind_pace:
|
|
324
|
+
pace_floor = 0.0
|
|
325
|
+
elif remaining_seconds <= SECONDS_PER_DAY and behind_pace:
|
|
326
|
+
pace_floor = 2.0
|
|
327
|
+
elif behind_pace:
|
|
328
|
+
pace_floor = 5.0
|
|
329
|
+
else:
|
|
330
|
+
pace_floor = 15.0
|
|
331
|
+
|
|
332
|
+
if _recent_foreground_activity(state, current):
|
|
333
|
+
pace_floor = max(pace_floor, 25.0)
|
|
334
|
+
|
|
335
|
+
return max(PRESERVE_MIN_WEEKLY_FLOOR_PCT, pace_floor)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _short_window_floor_pct(state: AccountState, current: float, *, preserve_count: int) -> float:
|
|
339
|
+
remaining_seconds = _seconds_until(state.reset_at, current)
|
|
340
|
+
floor = PRESERVE_MIN_SHORT_WINDOW_FLOOR_PCT
|
|
341
|
+
if remaining_seconds is None:
|
|
342
|
+
return 100.0
|
|
343
|
+
if remaining_seconds > SECONDS_PER_HOUR:
|
|
344
|
+
floor = max(floor, 20.0)
|
|
345
|
+
if _recent_foreground_activity(state, current):
|
|
346
|
+
floor = max(floor, 30.0)
|
|
347
|
+
if preserve_count <= 1:
|
|
348
|
+
floor = max(floor, 25.0)
|
|
349
|
+
return floor
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _preserve_allows_opportunistic_burn(state: AccountState, current: float, *, preserve_count: int) -> bool:
|
|
353
|
+
if _remaining_pct(state, secondary=True) is None or _remaining_pct(state, secondary=False) is None:
|
|
354
|
+
return False
|
|
355
|
+
if state.secondary_reset_at is None or state.reset_at is None:
|
|
356
|
+
return False
|
|
357
|
+
weekly_floor = _weekly_pace_floor_pct(state, current)
|
|
358
|
+
short_floor = _short_window_floor_pct(state, current, preserve_count=preserve_count)
|
|
359
|
+
return (_remaining_pct(state, secondary=True) or 0.0) > weekly_floor and (
|
|
360
|
+
_remaining_pct(state, secondary=False) or 0.0
|
|
361
|
+
) > short_floor
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _has_other_usable_foreground_capacity(
|
|
365
|
+
candidate: AccountState,
|
|
366
|
+
available: list[AccountState],
|
|
367
|
+
current: float,
|
|
368
|
+
) -> bool:
|
|
369
|
+
preserve_count = sum(1 for state in available if _routing_policy(state) == ROUTING_POLICY_PRESERVE)
|
|
370
|
+
for other in available:
|
|
371
|
+
if other.account_id == candidate.account_id:
|
|
372
|
+
continue
|
|
373
|
+
if other.status not in (AccountStatus.ACTIVE, AccountStatus.REAUTH_REQUIRED):
|
|
374
|
+
continue
|
|
375
|
+
if _routing_policy(other) == ROUTING_POLICY_PRESERVE:
|
|
376
|
+
if _preserve_allows_opportunistic_burn(other, current, preserve_count=preserve_count):
|
|
377
|
+
return True
|
|
378
|
+
continue
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _above_emergency_floor(state: AccountState) -> bool:
|
|
384
|
+
primary_remaining = _remaining_pct(state, secondary=False)
|
|
385
|
+
secondary_remaining = _remaining_pct(state, secondary=True)
|
|
386
|
+
if primary_remaining is None or secondary_remaining is None:
|
|
387
|
+
return False
|
|
388
|
+
return (
|
|
389
|
+
primary_remaining > NORMAL_LAST_ACCOUNT_EMERGENCY_FLOOR_PCT
|
|
390
|
+
and secondary_remaining > NORMAL_LAST_ACCOUNT_EMERGENCY_FLOOR_PCT
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _filter_opportunistic_candidates(
|
|
395
|
+
available: list[AccountState],
|
|
396
|
+
current: float,
|
|
397
|
+
) -> tuple[list[AccountState], str | None]:
|
|
398
|
+
burn_first: list[AccountState] = []
|
|
399
|
+
normal: list[AccountState] = []
|
|
400
|
+
preserve: list[AccountState] = []
|
|
401
|
+
preserve_count = sum(1 for state in available if _routing_policy(state) == ROUTING_POLICY_PRESERVE)
|
|
402
|
+
|
|
403
|
+
for state in available:
|
|
404
|
+
policy = _routing_policy(state)
|
|
405
|
+
if policy == ROUTING_POLICY_BURN_FIRST:
|
|
406
|
+
if _has_other_usable_foreground_capacity(state, available, current) or _above_emergency_floor(state):
|
|
407
|
+
burn_first.append(state)
|
|
408
|
+
elif policy == ROUTING_POLICY_PRESERVE:
|
|
409
|
+
if _preserve_allows_opportunistic_burn(state, current, preserve_count=preserve_count):
|
|
410
|
+
preserve.append(state)
|
|
411
|
+
else:
|
|
412
|
+
if _has_other_usable_foreground_capacity(state, available, current) or _above_emergency_floor(state):
|
|
413
|
+
normal.append(state)
|
|
414
|
+
|
|
415
|
+
if burn_first or normal or preserve:
|
|
416
|
+
return [*burn_first, *normal, *preserve], None
|
|
417
|
+
|
|
418
|
+
if any(_routing_policy(state) == ROUTING_POLICY_PRESERVE for state in available):
|
|
419
|
+
return [], "preserve floor or stale usage data blocks opportunistic burn"
|
|
420
|
+
return [], "no expendable account has emergency foreground reserve"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _reset_preference_bucket(state: AccountState, current: float, window: ResetPreferenceWindow) -> int:
|
|
424
|
+
if window == "primary":
|
|
425
|
+
reset_at = state.primary_reset_at
|
|
426
|
+
if reset_at is None:
|
|
427
|
+
reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
|
|
428
|
+
else:
|
|
429
|
+
reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
|
|
430
|
+
if reset_at is None:
|
|
431
|
+
reset_at = state.primary_reset_at
|
|
432
|
+
if reset_at is None:
|
|
433
|
+
return UNKNOWN_RESET_BUCKET_DAYS * SECONDS_PER_DAY
|
|
434
|
+
remaining_seconds = max(0, int(reset_at - current))
|
|
435
|
+
if window == "secondary":
|
|
436
|
+
return remaining_seconds // SECONDS_PER_DAY
|
|
437
|
+
return remaining_seconds
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _prefer_earlier_reset_candidates(
|
|
441
|
+
available: list[AccountState],
|
|
442
|
+
current: float,
|
|
443
|
+
window: ResetPreferenceWindow,
|
|
444
|
+
) -> list[AccountState]:
|
|
445
|
+
earliest_bucket = min(_reset_preference_bucket(state, current, window) for state in available)
|
|
446
|
+
return [state for state in available if _reset_preference_bucket(state, current, window) == earliest_bucket]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _fallback_secondary_capacity_credits(plan_type: str | None) -> float:
|
|
450
|
+
normalized = (plan_type or "").strip().lower()
|
|
451
|
+
resolved_plan = CAPACITY_PLAN_ALIASES.get(normalized, normalized or UNKNOWN_PLAN_FALLBACK)
|
|
452
|
+
return PLAN_CAPACITY_CREDITS_SECONDARY.get(
|
|
453
|
+
resolved_plan,
|
|
454
|
+
PLAN_CAPACITY_CREDITS_SECONDARY[UNKNOWN_PLAN_FALLBACK],
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _known_expired_reauth(state: AccountState, current: float) -> bool:
|
|
459
|
+
"""Return whether a warning-state account has crossed known token expiry."""
|
|
460
|
+
return (
|
|
461
|
+
state.status == AccountStatus.REAUTH_REQUIRED
|
|
462
|
+
and state.access_token_expires_at is not None
|
|
463
|
+
and state.access_token_expires_at <= current
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def select_account(
|
|
468
|
+
states: Iterable[AccountState],
|
|
469
|
+
now: float | None = None,
|
|
470
|
+
*,
|
|
471
|
+
prefer_earlier_reset: bool = False,
|
|
472
|
+
prefer_earlier_reset_window: ResetPreferenceWindow = "secondary",
|
|
473
|
+
routing_strategy: RoutingStrategy = "capacity_weighted",
|
|
474
|
+
allow_backoff_fallback: bool = True,
|
|
475
|
+
deterministic_probe: bool = False,
|
|
476
|
+
recovery_probe_only: bool = False,
|
|
477
|
+
relative_availability_power: float = DEFAULT_RELATIVE_AVAILABILITY_POWER,
|
|
478
|
+
relative_availability_top_k: int = DEFAULT_RELATIVE_AVAILABILITY_TOP_K,
|
|
479
|
+
usage_weighted_order: UsageWeightedOrder = "secondary_first",
|
|
480
|
+
traffic_class: TrafficClass = TRAFFIC_CLASS_FOREGROUND,
|
|
481
|
+
ignore_standard_quota: bool = False,
|
|
482
|
+
bypass_quota_exceeded: bool = False,
|
|
483
|
+
bypass_quota_exceeded_account_ids: Collection[str] | None = None,
|
|
484
|
+
primary_first_usage_weighted: bool = False,
|
|
485
|
+
routing_costs: RoutingCostsByAccount | None = None,
|
|
486
|
+
replica_salt: str | None = None,
|
|
487
|
+
selection_seed: str | None = None,
|
|
488
|
+
allow_usage_exhaustion_error: bool = True,
|
|
489
|
+
usage_exhaustion_states: Iterable[AccountState] | None = None,
|
|
490
|
+
) -> SelectionResult:
|
|
491
|
+
"""Select an eligible account by applying availability checks and routing strategy.
|
|
492
|
+
|
|
493
|
+
This function filters out accounts that cannot currently serve traffic
|
|
494
|
+
(for example paused, deactivated, still rate-limited, or in active
|
|
495
|
+
cooldown), attempts controlled recovery from transient error backoff,
|
|
496
|
+
and then chooses a candidate using the configured balancing strategy.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
states: Candidate account states to evaluate for the current request.
|
|
500
|
+
now: Unix timestamp in seconds used as the evaluation clock. If
|
|
501
|
+
``None``, the current system time is used.
|
|
502
|
+
prefer_earlier_reset: Whether to bias selection toward accounts whose
|
|
503
|
+
configured quota window resets sooner.
|
|
504
|
+
prefer_earlier_reset_window: Quota window used for reset preference
|
|
505
|
+
(``"primary"`` for short window or ``"secondary"`` for weekly),
|
|
506
|
+
with the other window used as fallback when the configured reset is
|
|
507
|
+
unavailable.
|
|
508
|
+
routing_strategy: Balancing strategy used to pick from the effective
|
|
509
|
+
pool (``"capacity_weighted"``, ``"sequential_drain"``,
|
|
510
|
+
``"reset_drain"``, ``"single_account"``, ``"relative_availability"``,
|
|
511
|
+
``"fill_first"``, ``"round_robin"``, or ``"usage_weighted"``).
|
|
512
|
+
allow_backoff_fallback: Whether to allow a fallback attempt with the
|
|
513
|
+
backoff account nearest to recovery when no fully available
|
|
514
|
+
account exists.
|
|
515
|
+
deterministic_probe: Whether weighted strategies should use a
|
|
516
|
+
deterministic probe order instead of random weighted choice.
|
|
517
|
+
recovery_probe_only: Whether to return only a due probing-account
|
|
518
|
+
recovery admission and otherwise return no selection. Health-tier
|
|
519
|
+
bypass strategies ignore this internal mode.
|
|
520
|
+
relative_availability_power: Exponent applied to normalized relative
|
|
521
|
+
availability weights.
|
|
522
|
+
relative_availability_top_k: Maximum number of highest-weight
|
|
523
|
+
relative-availability candidates retained before weighted draw.
|
|
524
|
+
usage_weighted_order: Whether usage-weighted routing ranks secondary
|
|
525
|
+
window pressure first, or primary-window pressure first for
|
|
526
|
+
budget-safe fallback selection.
|
|
527
|
+
traffic_class: Whether the request is normal foreground traffic or
|
|
528
|
+
opportunistic traffic that may only use explicitly expendable
|
|
529
|
+
account capacity.
|
|
530
|
+
ignore_standard_quota: Whether to ignore the account's standard
|
|
531
|
+
primary/secondary quota status. This is only for models that are
|
|
532
|
+
gated by a separate additional quota pool.
|
|
533
|
+
bypass_quota_exceeded: Backward-compatible alias for ignoring standard
|
|
534
|
+
quota status for gated quota selection.
|
|
535
|
+
bypass_quota_exceeded_account_ids: Optional narrower account-id scope
|
|
536
|
+
for the same bypass.
|
|
537
|
+
primary_first_usage_weighted: Whether usage-weighted routing should
|
|
538
|
+
rank by primary-window pressure before secondary-window pressure.
|
|
539
|
+
routing_costs: Optional request-scoped planner costs. Lower cost wins
|
|
540
|
+
after hard eligibility, health tier, and reset-bucket filtering.
|
|
541
|
+
selection_seed: Optional seed making the pick stable for a given
|
|
542
|
+
seed value instead of load-proportional. Applied to the pool the
|
|
543
|
+
configured strategy has already narrowed to, so it decides only
|
|
544
|
+
which of the accounts the strategy was about to draw from is
|
|
545
|
+
taken. Drain strategies (``sequential_drain``, ``reset_drain``,
|
|
546
|
+
``single_account``) have one correct answer and ignore it.
|
|
547
|
+
replica_salt: Optional per-replica salt mixed into the final
|
|
548
|
+
``round_robin`` tie-break so peer replicas break exact ties toward
|
|
549
|
+
different accounts. When ``None``, the process-wide salt configured
|
|
550
|
+
via ``configure_replica_salt`` (else the host identity) is used. It
|
|
551
|
+
affects only the final tie-break; the primary usage/health/cost
|
|
552
|
+
ordering is unchanged.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
A ``SelectionResult`` containing the selected ``AccountState`` and no
|
|
556
|
+
error message when routing can proceed, or ``None`` plus a
|
|
557
|
+
human-readable error message when no account is eligible.
|
|
558
|
+
"""
|
|
559
|
+
current = now or time.time()
|
|
560
|
+
available: list[AccountState] = []
|
|
561
|
+
in_error_backoff: list[AccountState] = []
|
|
562
|
+
all_states = list(states)
|
|
563
|
+
usage_exhaustion_state_list = list(usage_exhaustion_states) if usage_exhaustion_states is not None else all_states
|
|
564
|
+
bypass_account_ids = None if bypass_quota_exceeded_account_ids is None else set(bypass_quota_exceeded_account_ids)
|
|
565
|
+
|
|
566
|
+
for state in all_states:
|
|
567
|
+
bypass_standard_quota = (
|
|
568
|
+
ignore_standard_quota
|
|
569
|
+
or state.ignore_standard_quota
|
|
570
|
+
or bypass_quota_exceeded
|
|
571
|
+
or (bypass_account_ids is not None and state.account_id in bypass_account_ids)
|
|
572
|
+
)
|
|
573
|
+
if state.status == AccountStatus.DEACTIVATED:
|
|
574
|
+
continue
|
|
575
|
+
if state.status == AccountStatus.PAUSED:
|
|
576
|
+
continue
|
|
577
|
+
if _known_expired_reauth(state, current):
|
|
578
|
+
continue
|
|
579
|
+
if state.status == AccountStatus.RATE_LIMITED:
|
|
580
|
+
if state.reset_at and current >= state.reset_at:
|
|
581
|
+
state.status = AccountStatus.ACTIVE
|
|
582
|
+
state.used_percent = 0.0
|
|
583
|
+
state.error_count = 0
|
|
584
|
+
state.reset_at = None
|
|
585
|
+
elif not bypass_standard_quota:
|
|
586
|
+
continue
|
|
587
|
+
if state.status == AccountStatus.QUOTA_EXCEEDED:
|
|
588
|
+
if state.reset_at and current >= state.reset_at:
|
|
589
|
+
state.status = AccountStatus.ACTIVE
|
|
590
|
+
state.used_percent = 0.0
|
|
591
|
+
state.secondary_used_percent = 0.0
|
|
592
|
+
state.reset_at = None
|
|
593
|
+
elif not bypass_standard_quota:
|
|
594
|
+
continue
|
|
595
|
+
if state.cooldown_until and current >= state.cooldown_until:
|
|
596
|
+
state.cooldown_until = None
|
|
597
|
+
state.last_error_at = None
|
|
598
|
+
state.error_count = 0
|
|
599
|
+
if state.cooldown_until and current < state.cooldown_until:
|
|
600
|
+
continue
|
|
601
|
+
if state.error_count >= ERROR_BACKOFF_THRESHOLD:
|
|
602
|
+
backoff = min(300, 30 * (2 ** (state.error_count - ERROR_BACKOFF_THRESHOLD)))
|
|
603
|
+
if state.last_error_at and current - state.last_error_at < backoff:
|
|
604
|
+
in_error_backoff.append(state)
|
|
605
|
+
continue
|
|
606
|
+
# Error backoff expired — reset error state so recovery is
|
|
607
|
+
# not penalised by stale counts. The account has already
|
|
608
|
+
# been held back for the full backoff period; letting it
|
|
609
|
+
# re-enter the pool with a clean slate avoids the problem
|
|
610
|
+
# where a previously-high error_count causes an immediate
|
|
611
|
+
# return to maximum backoff on the very next transient error.
|
|
612
|
+
state.error_count = 0
|
|
613
|
+
state.last_error_at = None
|
|
614
|
+
available.append(state)
|
|
615
|
+
|
|
616
|
+
if traffic_class == TRAFFIC_CLASS_OPPORTUNISTIC and available:
|
|
617
|
+
opportunistic_available, reason = _filter_opportunistic_candidates(available, current)
|
|
618
|
+
if not opportunistic_available:
|
|
619
|
+
return SelectionResult(None, f"opportunistic burn window closed: {reason}")
|
|
620
|
+
available = opportunistic_available
|
|
621
|
+
|
|
622
|
+
if not available:
|
|
623
|
+
in_error_backoff_ids = {state.account_id for state in in_error_backoff}
|
|
624
|
+
hard_blocked_exists = any(
|
|
625
|
+
(
|
|
626
|
+
state.status
|
|
627
|
+
in (
|
|
628
|
+
AccountStatus.PAUSED,
|
|
629
|
+
AccountStatus.DEACTIVATED,
|
|
630
|
+
AccountStatus.RATE_LIMITED,
|
|
631
|
+
AccountStatus.QUOTA_EXCEEDED,
|
|
632
|
+
)
|
|
633
|
+
or _known_expired_reauth(state, current)
|
|
634
|
+
)
|
|
635
|
+
and state.account_id not in in_error_backoff_ids
|
|
636
|
+
for state in all_states
|
|
637
|
+
)
|
|
638
|
+
if allow_backoff_fallback and (len(in_error_backoff) > 1 or (in_error_backoff and hard_blocked_exists)):
|
|
639
|
+
|
|
640
|
+
def _backoff_expires_at(s: AccountState) -> float:
|
|
641
|
+
backoff = min(300, 30 * (2 ** (s.error_count - ERROR_BACKOFF_THRESHOLD)))
|
|
642
|
+
return (s.last_error_at or 0.0) + backoff
|
|
643
|
+
|
|
644
|
+
available.append(min(in_error_backoff, key=_backoff_expires_at))
|
|
645
|
+
if traffic_class == TRAFFIC_CLASS_OPPORTUNISTIC:
|
|
646
|
+
opportunistic_available, reason = _filter_opportunistic_candidates(available, current)
|
|
647
|
+
if not opportunistic_available:
|
|
648
|
+
return SelectionResult(None, f"opportunistic burn window closed: {reason}")
|
|
649
|
+
available = opportunistic_available
|
|
650
|
+
else:
|
|
651
|
+
if allow_usage_exhaustion_error:
|
|
652
|
+
usage_exhaustion = pool_usage_exhaustion(
|
|
653
|
+
usage_exhaustion_state_list,
|
|
654
|
+
current=current,
|
|
655
|
+
ignore_standard_quota=ignore_standard_quota or bypass_quota_exceeded,
|
|
656
|
+
ignore_standard_quota_account_ids=bypass_account_ids,
|
|
657
|
+
)
|
|
658
|
+
if usage_exhaustion is not None:
|
|
659
|
+
return usage_exhaustion
|
|
660
|
+
expired_reauth = [state for state in all_states if _known_expired_reauth(state, current)]
|
|
661
|
+
deactivated = [s for s in all_states if s.status == AccountStatus.DEACTIVATED]
|
|
662
|
+
paused = [s for s in all_states if s.status == AccountStatus.PAUSED]
|
|
663
|
+
rate_limited = [s for s in all_states if s.status == AccountStatus.RATE_LIMITED]
|
|
664
|
+
quota_exceeded = [s for s in all_states if s.status == AccountStatus.QUOTA_EXCEEDED]
|
|
665
|
+
|
|
666
|
+
if not rate_limited and not quota_exceeded:
|
|
667
|
+
if paused and expired_reauth and deactivated:
|
|
668
|
+
return SelectionResult(None, "All accounts are paused, deactivated, or require re-authentication")
|
|
669
|
+
if paused and expired_reauth:
|
|
670
|
+
return SelectionResult(None, "All accounts are paused or require re-authentication")
|
|
671
|
+
if paused and deactivated:
|
|
672
|
+
return SelectionResult(None, "All accounts are paused or deactivated")
|
|
673
|
+
if expired_reauth and deactivated:
|
|
674
|
+
return SelectionResult(None, "All accounts are deactivated or require re-authentication")
|
|
675
|
+
if paused:
|
|
676
|
+
return SelectionResult(None, "All accounts are paused")
|
|
677
|
+
if expired_reauth:
|
|
678
|
+
return SelectionResult(None, "All accounts require re-authentication")
|
|
679
|
+
if deactivated:
|
|
680
|
+
return SelectionResult(None, "All accounts are deactivated")
|
|
681
|
+
if quota_exceeded:
|
|
682
|
+
reset_candidates = [s.reset_at for s in quota_exceeded if s.reset_at]
|
|
683
|
+
if reset_candidates:
|
|
684
|
+
wait_seconds = max(0, min(reset_candidates) - int(current))
|
|
685
|
+
return SelectionResult(None, _format_retry_hint(wait_seconds))
|
|
686
|
+
cooldowns = [s.cooldown_until for s in all_states if s.cooldown_until and s.cooldown_until > current]
|
|
687
|
+
if cooldowns:
|
|
688
|
+
wait_seconds = max(0.0, min(cooldowns) - current)
|
|
689
|
+
return SelectionResult(None, _format_retry_hint(wait_seconds))
|
|
690
|
+
return SelectionResult(None, "No available accounts")
|
|
691
|
+
|
|
692
|
+
def _reset_first_sort_key(state: AccountState) -> tuple[int, float, float, float, float, str]:
|
|
693
|
+
reset_bucket_days = _reset_preference_bucket(state, current, prefer_earlier_reset_window)
|
|
694
|
+
secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
|
|
695
|
+
return (
|
|
696
|
+
reset_bucket_days,
|
|
697
|
+
_planner_cost(state, routing_costs),
|
|
698
|
+
secondary_used,
|
|
699
|
+
primary_used,
|
|
700
|
+
last_selected,
|
|
701
|
+
account_id,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
def _primary_reset_first_sort_key(state: AccountState) -> tuple[int, float, float, float, float, str]:
|
|
705
|
+
reset_bucket_days = _reset_preference_bucket(state, current, prefer_earlier_reset_window)
|
|
706
|
+
primary_used, secondary_used, last_selected, account_id = _primary_usage_sort_key(state)
|
|
707
|
+
return (
|
|
708
|
+
reset_bucket_days,
|
|
709
|
+
_planner_cost(state, routing_costs),
|
|
710
|
+
primary_used,
|
|
711
|
+
secondary_used,
|
|
712
|
+
last_selected,
|
|
713
|
+
account_id,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
def _primary_usage_sort_key_with_cost(state: AccountState) -> tuple[float, float, float, float, str]:
|
|
717
|
+
primary_used, secondary_used, last_selected, account_id = _primary_usage_sort_key(state)
|
|
718
|
+
return _planner_cost(state, routing_costs), primary_used, secondary_used, last_selected, account_id
|
|
719
|
+
|
|
720
|
+
def _usage_sort_key_with_cost(state: AccountState) -> tuple[float, float, float, float, str]:
|
|
721
|
+
secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
|
|
722
|
+
return _planner_cost(state, routing_costs), secondary_used, primary_used, last_selected, account_id
|
|
723
|
+
|
|
724
|
+
def _seeded_pick(pool: list[AccountState], seed: str) -> AccountState:
|
|
725
|
+
"""The strategy's draw, replaced by a stable per-seed choice.
|
|
726
|
+
|
|
727
|
+
Applied to the pool the strategy has already narrowed to -- its planner
|
|
728
|
+
cost and reset-preference filters run first -- so the seed decides only
|
|
729
|
+
*which* of the accounts the strategy was about to draw from is taken,
|
|
730
|
+
never whether an account it excluded becomes eligible.
|
|
731
|
+
"""
|
|
732
|
+
return _seeded_account(pool, seed)
|
|
733
|
+
|
|
734
|
+
round_robin_salt = _effective_replica_salt(replica_salt)
|
|
735
|
+
|
|
736
|
+
def _round_robin_sort_key(state: AccountState) -> tuple[float, float, str]:
|
|
737
|
+
# Primary ordering: lowest planner cost, then least-recently-selected.
|
|
738
|
+
# The final tie-break is decorrelated per replica (keyed hash of the
|
|
739
|
+
# account id) so peers spread exact ties across equally-good accounts
|
|
740
|
+
# instead of all herding onto the lexicographically-first account.
|
|
741
|
+
return (
|
|
742
|
+
_planner_cost(state, routing_costs),
|
|
743
|
+
state.last_selected_at or 0.0,
|
|
744
|
+
_decorrelated_tie_breaker(state.account_id, round_robin_salt),
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if routing_strategy == "single_account":
|
|
748
|
+
selected = min(available, key=lambda state: state.account_id)
|
|
749
|
+
return SelectionResult(selected, None)
|
|
750
|
+
|
|
751
|
+
if routing_strategy == "sequential_drain":
|
|
752
|
+
selected = min(available, key=_sequential_drain_sort_key)
|
|
753
|
+
return SelectionResult(selected, None)
|
|
754
|
+
|
|
755
|
+
if routing_strategy == "reset_drain":
|
|
756
|
+
selected = min(available, key=lambda state: _reset_drain_sort_key(state, current))
|
|
757
|
+
return SelectionResult(selected, None)
|
|
758
|
+
|
|
759
|
+
healthy = [s for s in available if s.health_tier == HEALTH_TIER_HEALTHY]
|
|
760
|
+
probing = [s for s in available if s.health_tier == HEALTH_TIER_PROBING]
|
|
761
|
+
draining = [s for s in available if s.health_tier == HEALTH_TIER_DRAINING]
|
|
762
|
+
# Recovery is a liveness admission inside the already-eligible pool. Pick
|
|
763
|
+
# it before routing-policy preferences so burn/preserve policy cannot make
|
|
764
|
+
# PROBING permanent, but never before quota/cooldown/security filtering.
|
|
765
|
+
# A seeded caller never carries the probe. The due probe is whichever
|
|
766
|
+
# probing account went quiet longest, so admitting one advances its clock
|
|
767
|
+
# and hands the next turn to a different sibling -- the rotation the seed
|
|
768
|
+
# exists to prevent, and the conversation fan-out the seed exists to stop.
|
|
769
|
+
# Probes ride on unbound traffic and on every other thread, so this exempts
|
|
770
|
+
# the handful of conversations being held warm through an isolation window
|
|
771
|
+
# rather than starving recovery. ``recovery_probe_only`` is the probe pass
|
|
772
|
+
# itself and is never seeded.
|
|
773
|
+
due_probe = (
|
|
774
|
+
_oldest_due_probing_account(probing, current=current)
|
|
775
|
+
if (healthy or recovery_probe_only) and selection_seed is None
|
|
776
|
+
else None
|
|
777
|
+
)
|
|
778
|
+
if recovery_probe_only and due_probe is None:
|
|
779
|
+
return SelectionResult(None, None)
|
|
780
|
+
health_pool = [due_probe] if due_probe is not None else healthy or probing or draining or available
|
|
781
|
+
burn_first = [s for s in health_pool if _routing_policy(s) == ROUTING_POLICY_BURN_FIRST]
|
|
782
|
+
normal = [s for s in health_pool if _routing_policy(s) == ROUTING_POLICY_NORMAL]
|
|
783
|
+
preserve = [s for s in health_pool if _routing_policy(s) == ROUTING_POLICY_PRESERVE]
|
|
784
|
+
effective_pool = burn_first or normal or preserve or health_pool
|
|
785
|
+
effective_prefer_earlier_reset = prefer_earlier_reset and routing_strategy != "relative_availability"
|
|
786
|
+
|
|
787
|
+
if routing_strategy == "round_robin":
|
|
788
|
+
selected = (
|
|
789
|
+
_seeded_pick(_lowest_planner_cost_candidates(effective_pool, routing_costs), selection_seed)
|
|
790
|
+
if selection_seed is not None
|
|
791
|
+
else min(effective_pool, key=_round_robin_sort_key)
|
|
792
|
+
)
|
|
793
|
+
elif routing_strategy == "capacity_weighted":
|
|
794
|
+
candidate_pool = (
|
|
795
|
+
_prefer_earlier_reset_candidates(effective_pool, current, prefer_earlier_reset_window)
|
|
796
|
+
if effective_prefer_earlier_reset
|
|
797
|
+
else effective_pool
|
|
798
|
+
)
|
|
799
|
+
if deterministic_probe:
|
|
800
|
+
selected = min(candidate_pool, key=lambda state: _capacity_probe_sort_key_with_cost(state, routing_costs))
|
|
801
|
+
else:
|
|
802
|
+
candidate_pool = _lowest_planner_cost_candidates(candidate_pool, routing_costs)
|
|
803
|
+
selected = _select_capacity_weighted(candidate_pool, selection_seed=selection_seed)
|
|
804
|
+
elif routing_strategy == "relative_availability":
|
|
805
|
+
candidate_pool = _lowest_planner_cost_candidates(effective_pool, routing_costs)
|
|
806
|
+
selected = _select_relative_availability(
|
|
807
|
+
candidate_pool,
|
|
808
|
+
current=current,
|
|
809
|
+
power=relative_availability_power,
|
|
810
|
+
top_k=relative_availability_top_k,
|
|
811
|
+
deterministic_probe=deterministic_probe,
|
|
812
|
+
selection_seed=selection_seed,
|
|
813
|
+
)
|
|
814
|
+
elif routing_strategy == "fill_first":
|
|
815
|
+
candidate_pool = (
|
|
816
|
+
_prefer_earlier_reset_candidates(effective_pool, current, prefer_earlier_reset_window)
|
|
817
|
+
if prefer_earlier_reset
|
|
818
|
+
else effective_pool
|
|
819
|
+
)
|
|
820
|
+
# ``fill_first`` deliberately ranks by *highest* usage to drain an
|
|
821
|
+
# account before opening the next, and that ranking is already stable
|
|
822
|
+
# across admissions, so the seed has nothing to add and would only
|
|
823
|
+
# scatter threads onto fresh accounts.
|
|
824
|
+
selected = _select_fill_first(candidate_pool)
|
|
825
|
+
else:
|
|
826
|
+
effective_usage_weighted_order: UsageWeightedOrder = (
|
|
827
|
+
"primary_first" if primary_first_usage_weighted else usage_weighted_order
|
|
828
|
+
)
|
|
829
|
+
if selection_seed is not None:
|
|
830
|
+
# The sort keys this branch would otherwise use put reset bucket
|
|
831
|
+
# first and planner cost second, so the seed has to be spent below
|
|
832
|
+
# both: a thread must not be held on a 30-day-reset or high-cost
|
|
833
|
+
# account the ordering had already ruled out.
|
|
834
|
+
seeded_pool = (
|
|
835
|
+
_prefer_earlier_reset_candidates(effective_pool, current, prefer_earlier_reset_window)
|
|
836
|
+
if effective_prefer_earlier_reset
|
|
837
|
+
else effective_pool
|
|
838
|
+
)
|
|
839
|
+
selected = _seeded_pick(_lowest_planner_cost_candidates(seeded_pool, routing_costs), selection_seed)
|
|
840
|
+
elif effective_usage_weighted_order == "primary_first":
|
|
841
|
+
selected = min(
|
|
842
|
+
effective_pool,
|
|
843
|
+
key=(
|
|
844
|
+
_primary_reset_first_sort_key
|
|
845
|
+
if effective_prefer_earlier_reset
|
|
846
|
+
else _primary_usage_sort_key_with_cost
|
|
847
|
+
),
|
|
848
|
+
)
|
|
849
|
+
else:
|
|
850
|
+
selected = min(
|
|
851
|
+
effective_pool,
|
|
852
|
+
key=_reset_first_sort_key if effective_prefer_earlier_reset else _usage_sort_key_with_cost,
|
|
853
|
+
)
|
|
854
|
+
return SelectionResult(selected, None)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _oldest_due_probing_account(
|
|
858
|
+
probing: Iterable[AccountState],
|
|
859
|
+
*,
|
|
860
|
+
current: float,
|
|
861
|
+
) -> AccountState | None:
|
|
862
|
+
due = [
|
|
863
|
+
state
|
|
864
|
+
for state in probing
|
|
865
|
+
if state.last_selected_at is None or current - state.last_selected_at >= PROBE_QUIET_SECONDS
|
|
866
|
+
]
|
|
867
|
+
if not due:
|
|
868
|
+
return None
|
|
869
|
+
return min(
|
|
870
|
+
due,
|
|
871
|
+
key=lambda state: (
|
|
872
|
+
state.last_selected_at if state.last_selected_at is not None else float("-inf"),
|
|
873
|
+
state.account_id,
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _remaining_secondary_credits(state: AccountState) -> float:
|
|
879
|
+
"""Return remaining absolute credits for the secondary (7-day) window."""
|
|
880
|
+
capacity = (
|
|
881
|
+
state.priority_capacity_credits if state.priority_capacity_credits is not None else state.capacity_credits
|
|
882
|
+
)
|
|
883
|
+
if capacity is None:
|
|
884
|
+
capacity = _fallback_secondary_capacity_credits(state.plan_type)
|
|
885
|
+
elif capacity <= 0:
|
|
886
|
+
return 0.0
|
|
887
|
+
primary_used = _priority_primary_used(state)
|
|
888
|
+
used_pct = _priority_secondary_used(state, primary_used)
|
|
889
|
+
return max(0.0, capacity * (1.0 - min(used_pct, 100.0) / 100.0))
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _priority_primary_used(state: AccountState) -> float:
|
|
893
|
+
value = state.priority_used_percent if state.priority_used_percent is not None else state.used_percent
|
|
894
|
+
return value if value is not None else 0.0
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _priority_secondary_used(state: AccountState, primary_used: float | None = None) -> float:
|
|
898
|
+
if state.limit_scoped_usage and state.priority_secondary_used_percent is None:
|
|
899
|
+
return primary_used if primary_used is not None else _priority_primary_used(state)
|
|
900
|
+
value = (
|
|
901
|
+
state.priority_secondary_used_percent
|
|
902
|
+
if state.priority_secondary_used_percent is not None
|
|
903
|
+
else state.secondary_used_percent
|
|
904
|
+
)
|
|
905
|
+
if value is not None:
|
|
906
|
+
return value
|
|
907
|
+
return primary_used if primary_used is not None else _priority_primary_used(state)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _relative_availability_divisor_seconds(state: AccountState, current: float) -> float:
|
|
911
|
+
reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
|
|
912
|
+
if reset_at is None:
|
|
913
|
+
remaining_seconds = float(UNKNOWN_RESET_FALLBACK_SECONDS)
|
|
914
|
+
else:
|
|
915
|
+
remaining_seconds = max(0.0, float(reset_at) - current)
|
|
916
|
+
return max(remaining_seconds, float(RELATIVE_AVAILABILITY_MIN_DIVISOR_SECONDS))
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _relative_availability_remaining_seconds(state: AccountState, current: float) -> float:
|
|
920
|
+
reset_at = state.priority_reset_at if state.priority_reset_at is not None else state.secondary_reset_at
|
|
921
|
+
if reset_at is None:
|
|
922
|
+
return float(UNKNOWN_RESET_FALLBACK_SECONDS)
|
|
923
|
+
return max(0.0, float(reset_at) - current)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _relative_availability_raw_score(state: AccountState, current: float) -> float:
|
|
927
|
+
remaining_credits = _remaining_secondary_credits(state)
|
|
928
|
+
if remaining_credits <= 0.0:
|
|
929
|
+
return 0.0
|
|
930
|
+
return remaining_credits / _relative_availability_divisor_seconds(state, current)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _relative_availability_label(state: AccountState) -> str:
|
|
934
|
+
return state.account_id
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _relative_availability_score_per_minute(raw_score: float) -> float:
|
|
938
|
+
return raw_score * 60.0
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _log_relative_availability_candidate_scores(
|
|
942
|
+
raw_scores: list[tuple[AccountState, float]],
|
|
943
|
+
*,
|
|
944
|
+
current: float,
|
|
945
|
+
) -> None:
|
|
946
|
+
for state, raw_score in raw_scores:
|
|
947
|
+
remaining_seconds = _relative_availability_remaining_seconds(state, current)
|
|
948
|
+
logger.debug(
|
|
949
|
+
(
|
|
950
|
+
f"{_RELATIVE_AVAILABILITY_LOG_PREFIX_CANDIDATE}account=%s "
|
|
951
|
+
"remaining_credits=%.2f remaining_minutes=%.2f score_per_minute=%.6f"
|
|
952
|
+
),
|
|
953
|
+
_relative_availability_label(state),
|
|
954
|
+
_remaining_secondary_credits(state),
|
|
955
|
+
remaining_seconds / 60.0,
|
|
956
|
+
_relative_availability_score_per_minute(raw_score),
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _log_relative_availability_top_k(
|
|
961
|
+
weighted_candidates: list[tuple[AccountState, float, float]],
|
|
962
|
+
*,
|
|
963
|
+
current: float,
|
|
964
|
+
) -> None:
|
|
965
|
+
formatted_candidates = ", ".join(
|
|
966
|
+
(
|
|
967
|
+
f"account={_relative_availability_label(state)} "
|
|
968
|
+
f"remaining_credits={_remaining_secondary_credits(state):.2f} "
|
|
969
|
+
f"remaining_minutes={_relative_availability_remaining_seconds(state, current) / 60.0:.2f} "
|
|
970
|
+
f"score_per_minute={_relative_availability_score_per_minute(raw_score):.6f} "
|
|
971
|
+
f"weight={weight:.8f}"
|
|
972
|
+
)
|
|
973
|
+
for state, weight, raw_score in weighted_candidates
|
|
974
|
+
)
|
|
975
|
+
logger.info("%s%s", _RELATIVE_AVAILABILITY_LOG_PREFIX_TOP_K, formatted_candidates)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _relative_availability_weighted_candidates(
|
|
979
|
+
available: list[AccountState],
|
|
980
|
+
*,
|
|
981
|
+
current: float,
|
|
982
|
+
power: float,
|
|
983
|
+
top_k: int,
|
|
984
|
+
membership_seed: str | None = None,
|
|
985
|
+
) -> list[tuple[AccountState, float, float]]:
|
|
986
|
+
raw_scores = [(state, _relative_availability_raw_score(state, current)) for state in available]
|
|
987
|
+
_log_relative_availability_candidate_scores(raw_scores, current=current)
|
|
988
|
+
best_raw_score = max((score for _, score in raw_scores), default=0.0)
|
|
989
|
+
if best_raw_score <= 0.0:
|
|
990
|
+
return []
|
|
991
|
+
|
|
992
|
+
weighted: list[tuple[AccountState, float, float]] = []
|
|
993
|
+
safe_power = power if power > 0.0 else DEFAULT_RELATIVE_AVAILABILITY_POWER
|
|
994
|
+
for state, raw_score in raw_scores:
|
|
995
|
+
normalized_score = raw_score / best_raw_score
|
|
996
|
+
weight = normalized_score**safe_power
|
|
997
|
+
# The floor is an eligibility bound, not a ranking, so it applies to a
|
|
998
|
+
# seeded caller too: a retained thread must not be parked on an account
|
|
999
|
+
# that has fallen far behind the best available one.
|
|
1000
|
+
if weight < RELATIVE_AVAILABILITY_MIN_WEIGHT_FRACTION:
|
|
1001
|
+
continue
|
|
1002
|
+
weighted.append((state, weight, raw_score))
|
|
1003
|
+
|
|
1004
|
+
if not weighted:
|
|
1005
|
+
return []
|
|
1006
|
+
|
|
1007
|
+
if membership_seed is not None:
|
|
1008
|
+
# ``_usage_sort_key`` ends in ``last_selected_at``, so exact ties are
|
|
1009
|
+
# broken by *recency*: each admitted turn advances the winner's
|
|
1010
|
+
# timestamp and ejects it from the top-k on the next turn. Ordering by
|
|
1011
|
+
# the caller's own seed instead is stable turn to turn, and keyed per
|
|
1012
|
+
# thread rather than globally -- otherwise every retained conversation
|
|
1013
|
+
# is handed the same k accounts and the rest of an equally scored pool
|
|
1014
|
+
# never serves a turn.
|
|
1015
|
+
weighted.sort(key=lambda item: _decorrelated_tie_breaker(item[0].account_id, membership_seed))
|
|
1016
|
+
else:
|
|
1017
|
+
weighted.sort(key=lambda item: (-item[1], -item[2], *_usage_sort_key(item[0])))
|
|
1018
|
+
# The ``k`` cut is a *rank* cut over live availability, so a sibling can
|
|
1019
|
+
# stay well within reach and still drop out of it when another account's
|
|
1020
|
+
# usage refreshes. A seeded caller keeps every candidate above the minimum
|
|
1021
|
+
# weight fraction instead: that floor moves only when an account genuinely
|
|
1022
|
+
# falls away from the best, which is a change in the pool rather than a
|
|
1023
|
+
# reshuffle of it.
|
|
1024
|
+
safe_top_k = len(weighted) if membership_seed is not None else max(1, top_k)
|
|
1025
|
+
top_candidates = weighted[:safe_top_k]
|
|
1026
|
+
_log_relative_availability_top_k(top_candidates, current=current)
|
|
1027
|
+
return top_candidates
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _log_relative_availability_winner(
|
|
1031
|
+
winner: AccountState,
|
|
1032
|
+
*,
|
|
1033
|
+
current: float,
|
|
1034
|
+
weight: float | None,
|
|
1035
|
+
raw_score: float,
|
|
1036
|
+
) -> None:
|
|
1037
|
+
remaining_seconds = _relative_availability_remaining_seconds(winner, current)
|
|
1038
|
+
logger.info(
|
|
1039
|
+
(
|
|
1040
|
+
f"{_RELATIVE_AVAILABILITY_LOG_PREFIX_WINNER}account=%s "
|
|
1041
|
+
"remaining_credits=%.2f remaining_minutes=%.2f score_per_minute=%.6f weight=%s"
|
|
1042
|
+
),
|
|
1043
|
+
_relative_availability_label(winner),
|
|
1044
|
+
_remaining_secondary_credits(winner),
|
|
1045
|
+
remaining_seconds / 60.0,
|
|
1046
|
+
_relative_availability_score_per_minute(raw_score),
|
|
1047
|
+
f"{weight:.8f}" if weight is not None else "fallback",
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _select_relative_availability(
|
|
1052
|
+
available: list[AccountState],
|
|
1053
|
+
*,
|
|
1054
|
+
current: float,
|
|
1055
|
+
power: float,
|
|
1056
|
+
top_k: int,
|
|
1057
|
+
deterministic_probe: bool,
|
|
1058
|
+
selection_seed: str | None = None,
|
|
1059
|
+
) -> AccountState:
|
|
1060
|
+
weighted_candidates = _relative_availability_weighted_candidates(
|
|
1061
|
+
available,
|
|
1062
|
+
current=current,
|
|
1063
|
+
power=power,
|
|
1064
|
+
top_k=top_k,
|
|
1065
|
+
membership_seed=selection_seed,
|
|
1066
|
+
)
|
|
1067
|
+
if not weighted_candidates:
|
|
1068
|
+
winner = (
|
|
1069
|
+
_seeded_least_used(available, selection_seed)
|
|
1070
|
+
if selection_seed is not None
|
|
1071
|
+
else min(available, key=_usage_sort_key)
|
|
1072
|
+
)
|
|
1073
|
+
_log_relative_availability_winner(
|
|
1074
|
+
winner,
|
|
1075
|
+
current=current,
|
|
1076
|
+
weight=None,
|
|
1077
|
+
raw_score=_relative_availability_raw_score(winner, current),
|
|
1078
|
+
)
|
|
1079
|
+
return winner
|
|
1080
|
+
if deterministic_probe:
|
|
1081
|
+
winner, weight, raw_score = weighted_candidates[0]
|
|
1082
|
+
_log_relative_availability_winner(winner, current=current, weight=weight, raw_score=raw_score)
|
|
1083
|
+
return winner
|
|
1084
|
+
states = [state for state, _, _ in weighted_candidates]
|
|
1085
|
+
# Top-k membership is decided by availability alone (above); the recent
|
|
1086
|
+
# error rate only discounts the draw so a penalized account still competes.
|
|
1087
|
+
weights = [weight * _selection_weight_multiplier(state) for state, weight, _ in weighted_candidates]
|
|
1088
|
+
total = sum(weights)
|
|
1089
|
+
if total <= 0.0:
|
|
1090
|
+
winner = (
|
|
1091
|
+
_seeded_least_used(available, selection_seed)
|
|
1092
|
+
if selection_seed is not None
|
|
1093
|
+
else min(available, key=_usage_sort_key)
|
|
1094
|
+
)
|
|
1095
|
+
_log_relative_availability_winner(
|
|
1096
|
+
winner,
|
|
1097
|
+
current=current,
|
|
1098
|
+
weight=None,
|
|
1099
|
+
raw_score=_relative_availability_raw_score(winner, current),
|
|
1100
|
+
)
|
|
1101
|
+
return winner
|
|
1102
|
+
if selection_seed is not None:
|
|
1103
|
+
# Inside the top-k the draw has already been narrowed by availability,
|
|
1104
|
+
# and membership is stable for a seeded caller (see
|
|
1105
|
+
# ``stable_membership``), so the seed picks uniformly among the
|
|
1106
|
+
# survivors. Unlike the capacity draw, these weights are *normalized*
|
|
1107
|
+
# against the current best score: they move whenever any account's
|
|
1108
|
+
# availability moves, including the retained thread's own substitute as
|
|
1109
|
+
# it serves. Weighting the pick by them would put every thread one
|
|
1110
|
+
# admission away from flipping, which is the rotation the seed exists
|
|
1111
|
+
# to prevent -- so here the narrowing carries the availability
|
|
1112
|
+
# preference and the seed only chooses among what survived it.
|
|
1113
|
+
# Zero-weight candidates are dropped as in the capacity draw.
|
|
1114
|
+
winner = _seeded_account(
|
|
1115
|
+
[state for state, weight in zip(states, weights, strict=True) if weight > 0.0],
|
|
1116
|
+
selection_seed,
|
|
1117
|
+
)
|
|
1118
|
+
else:
|
|
1119
|
+
winner = random.choices(states, weights=weights, k=1)[0]
|
|
1120
|
+
for state, weight, raw_score in weighted_candidates:
|
|
1121
|
+
if state.account_id == winner.account_id:
|
|
1122
|
+
_log_relative_availability_winner(winner, current=current, weight=weight, raw_score=raw_score)
|
|
1123
|
+
break
|
|
1124
|
+
return winner
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _seeded_account(pool: list[AccountState], seed: str) -> AccountState:
|
|
1128
|
+
"""The seed's choice among candidates the strategy was about to draw from."""
|
|
1129
|
+
return min(pool, key=lambda state: _decorrelated_tie_breaker(state.account_id, seed))
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _seeded_least_used(available: list[AccountState], seed: str) -> AccountState:
|
|
1133
|
+
"""The exhausted-pool fallback, made stable for a seeded caller.
|
|
1134
|
+
|
|
1135
|
+
``min(available, key=_usage_sort_key)`` ends in ``last_selected_at``, so
|
|
1136
|
+
when every candidate is equally spent -- which is exactly when this
|
|
1137
|
+
fallback runs -- being chosen is what loses you the next turn, and the
|
|
1138
|
+
caller's thread rotates through the tied accounts. Seed the tie instead,
|
|
1139
|
+
keeping the usage ordering that decides which accounts are tied at all.
|
|
1140
|
+
"""
|
|
1141
|
+
least_used = min(_usage_sort_key(state)[:2] for state in available)
|
|
1142
|
+
return _seeded_account([state for state in available if _usage_sort_key(state)[:2] == least_used], seed)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _stable_tie_breaker(account_id: str) -> str:
|
|
1146
|
+
return hashlib.sha256(account_id.encode("utf-8")).hexdigest()
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
# ---------------------------------------------------------------------------
|
|
1150
|
+
# Cross-replica round-robin decorrelation
|
|
1151
|
+
#
|
|
1152
|
+
# ``round_robin`` orders candidates by planner cost, then least-recently-
|
|
1153
|
+
# selected time, then a stable final tie-break. In a multi-replica deployment
|
|
1154
|
+
# every replica shares one account set and computes the identical key, so an
|
|
1155
|
+
# *exact* tie on the primary keys (for example a cold start where every account
|
|
1156
|
+
# is never-selected, or several accounts whose ``last_selected_at`` reset to
|
|
1157
|
+
# ``0.0``) makes every replica break the tie toward the same account -- herding
|
|
1158
|
+
# load onto it. Mixing a stable per-replica salt into the final tie-break
|
|
1159
|
+
# decorrelates that choice so peers spread across equally-good accounts, without
|
|
1160
|
+
# touching the primary ordering.
|
|
1161
|
+
# ---------------------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
_configured_replica_salt: str | None = None
|
|
1164
|
+
_process_replica_salt: str | None = None
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def configure_replica_salt(salt: str | None) -> None:
|
|
1168
|
+
"""Set the process-wide replica salt used to decorrelate round-robin ties.
|
|
1169
|
+
|
|
1170
|
+
Called once during proxy start-up with the replica's stable identity (the
|
|
1171
|
+
HTTP responses-session bridge instance id). An empty or ``None`` value
|
|
1172
|
+
clears the override and restores the lazily-resolved host default. The salt
|
|
1173
|
+
is process-stable by design: it must not change between selections so a
|
|
1174
|
+
replica breaks a given tie the same way every time.
|
|
1175
|
+
"""
|
|
1176
|
+
global _configured_replica_salt
|
|
1177
|
+
normalized = salt.strip() if salt else ""
|
|
1178
|
+
_configured_replica_salt = normalized or None
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _default_replica_salt() -> str:
|
|
1182
|
+
"""Return the cached host-identity fallback salt.
|
|
1183
|
+
|
|
1184
|
+
Resolved once per process so the salt never varies between calls within a
|
|
1185
|
+
replica, keeping single-replica selection deterministic. Matches the HTTP
|
|
1186
|
+
bridge instance-id default (the host name) so an unconfigured process still
|
|
1187
|
+
decorrelates by pod/host.
|
|
1188
|
+
"""
|
|
1189
|
+
global _process_replica_salt
|
|
1190
|
+
if _process_replica_salt is None:
|
|
1191
|
+
hostname = socket.gethostname().strip()
|
|
1192
|
+
_process_replica_salt = hostname or "codex-lb"
|
|
1193
|
+
return _process_replica_salt
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _effective_replica_salt(explicit: str | None) -> str:
|
|
1197
|
+
if explicit is not None and explicit.strip():
|
|
1198
|
+
return explicit.strip()
|
|
1199
|
+
if _configured_replica_salt is not None:
|
|
1200
|
+
return _configured_replica_salt
|
|
1201
|
+
return _default_replica_salt()
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _decorrelated_tie_breaker(account_id: str, salt: str) -> str:
|
|
1205
|
+
"""Keyed hash of ``account_id`` under a per-replica ``salt``.
|
|
1206
|
+
|
|
1207
|
+
Deterministic for a given ``(salt, account_id)`` pair -- a replica always
|
|
1208
|
+
breaks the same tie the same way -- while distinct salts yield independent
|
|
1209
|
+
orderings across replicas.
|
|
1210
|
+
"""
|
|
1211
|
+
return hashlib.sha256(f"{salt}\x00{account_id}".encode("utf-8")).hexdigest()
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _configured_capacity_credits(state: AccountState) -> float:
|
|
1215
|
+
if state.capacity_credits is not None and state.capacity_credits > 0:
|
|
1216
|
+
return state.capacity_credits
|
|
1217
|
+
return _fallback_secondary_capacity_credits(state.plan_type)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _sequential_drain_sort_key(state: AccountState) -> tuple[float, str, str]:
|
|
1221
|
+
return (
|
|
1222
|
+
_configured_capacity_credits(state),
|
|
1223
|
+
_stable_tie_breaker(state.account_id),
|
|
1224
|
+
state.account_id,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _weekly_reset_timestamp(state: AccountState, current: float) -> float:
|
|
1229
|
+
if state.secondary_reset_at is not None and float(state.secondary_reset_at) > current:
|
|
1230
|
+
return float(state.secondary_reset_at)
|
|
1231
|
+
if state.reset_at is not None and float(state.reset_at) > current:
|
|
1232
|
+
return float(state.reset_at)
|
|
1233
|
+
return float("inf")
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _reset_drain_sort_key(state: AccountState, current: float) -> tuple[int, float, float, float, str, str]:
|
|
1237
|
+
primary_remaining = 100.0 - (state.used_percent if state.used_percent is not None else 0.0)
|
|
1238
|
+
secondary_remaining = 100.0 - (
|
|
1239
|
+
state.secondary_used_percent
|
|
1240
|
+
if state.secondary_used_percent is not None
|
|
1241
|
+
else state.used_percent
|
|
1242
|
+
if state.used_percent is not None
|
|
1243
|
+
else 0.0
|
|
1244
|
+
)
|
|
1245
|
+
reset_at = _weekly_reset_timestamp(state, current)
|
|
1246
|
+
reset_bucket_days = (
|
|
1247
|
+
UNKNOWN_RESET_BUCKET_DAYS if reset_at == float("inf") else max(0, int((reset_at - current) // SECONDS_PER_DAY))
|
|
1248
|
+
)
|
|
1249
|
+
return (
|
|
1250
|
+
reset_bucket_days,
|
|
1251
|
+
-max(0.0, primary_remaining),
|
|
1252
|
+
-max(0.0, secondary_remaining),
|
|
1253
|
+
reset_at,
|
|
1254
|
+
_stable_tie_breaker(state.account_id),
|
|
1255
|
+
state.account_id,
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _capacity_probe_sort_key_with_cost(
|
|
1260
|
+
state: AccountState,
|
|
1261
|
+
routing_costs: RoutingCostsByAccount | None,
|
|
1262
|
+
) -> tuple[float, float, float, float, float, str]:
|
|
1263
|
+
secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
|
|
1264
|
+
return (
|
|
1265
|
+
_planner_cost(state, routing_costs),
|
|
1266
|
+
-_remaining_secondary_credits(state),
|
|
1267
|
+
secondary_used,
|
|
1268
|
+
primary_used,
|
|
1269
|
+
last_selected,
|
|
1270
|
+
account_id,
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _lowest_planner_cost_candidates(
|
|
1275
|
+
available: list[AccountState],
|
|
1276
|
+
routing_costs: RoutingCostsByAccount | None,
|
|
1277
|
+
) -> list[AccountState]:
|
|
1278
|
+
if not routing_costs:
|
|
1279
|
+
return available
|
|
1280
|
+
lowest_cost = min(_planner_cost(state, routing_costs) for state in available)
|
|
1281
|
+
return [state for state in available if _planner_cost(state, routing_costs) == lowest_cost]
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _select_capacity_weighted(
|
|
1285
|
+
available: list[AccountState],
|
|
1286
|
+
*,
|
|
1287
|
+
selection_seed: str | None = None,
|
|
1288
|
+
) -> AccountState:
|
|
1289
|
+
"""Select an account with probability proportional to remaining secondary credits."""
|
|
1290
|
+
weights = [_remaining_secondary_credits(s) * _selection_weight_multiplier(s) for s in available]
|
|
1291
|
+
total = sum(weights)
|
|
1292
|
+
if total <= 0.0:
|
|
1293
|
+
# All accounts exhausted — fall back to deterministic usage-weighted
|
|
1294
|
+
if selection_seed is not None:
|
|
1295
|
+
return _seeded_least_used(available, selection_seed)
|
|
1296
|
+
return min(available, key=_usage_sort_key)
|
|
1297
|
+
if selection_seed is not None:
|
|
1298
|
+
# A zero-weight account is one the draw could never have returned while
|
|
1299
|
+
# a positive-weight sibling existed -- an account whose effective
|
|
1300
|
+
# secondary credits are spent. The seed must not readmit it.
|
|
1301
|
+
#
|
|
1302
|
+
# The *magnitudes* are deliberately dropped here, and only here. They
|
|
1303
|
+
# are live: a usage refresh, an elapsed reset, or an error-rate update
|
|
1304
|
+
# moves them without anything about eligibility changing, so a pick
|
|
1305
|
+
# weighted by them puts every seeded thread one refresh away from
|
|
1306
|
+
# flipping -- which is the fan-out this seed exists to prevent. Ordinary
|
|
1307
|
+
# (unseeded) traffic keeps the full weighted draw, so the pool-level
|
|
1308
|
+
# capacity balance is unchanged; a retained thread simply lands
|
|
1309
|
+
# uniformly among the accounts that draw had already accepted.
|
|
1310
|
+
return _seeded_account(
|
|
1311
|
+
[state for state, weight in zip(available, weights, strict=True) if weight > 0.0],
|
|
1312
|
+
selection_seed,
|
|
1313
|
+
)
|
|
1314
|
+
return random.choices(available, weights=weights, k=1)[0]
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _selection_weight_multiplier(state: AccountState) -> float:
|
|
1318
|
+
"""Clamp the balancer-supplied draw multiplier so a bad value can never
|
|
1319
|
+
negate or unbound a weight."""
|
|
1320
|
+
multiplier = state.selection_weight_multiplier
|
|
1321
|
+
if multiplier != multiplier or multiplier < 0.0: # NaN or negative
|
|
1322
|
+
return 1.0
|
|
1323
|
+
return min(multiplier, 1.0)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _fill_first_sort_key(state: AccountState) -> tuple[float, float, str]:
|
|
1327
|
+
primary_used = state.used_percent if state.used_percent is not None else 0.0
|
|
1328
|
+
secondary_used = state.secondary_used_percent if state.secondary_used_percent is not None else 0.0
|
|
1329
|
+
return -primary_used, -secondary_used, state.account_id
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _select_fill_first(available: list[AccountState]) -> AccountState:
|
|
1333
|
+
"""Pick the eligible account with the highest primary 5h ``used_percent``.
|
|
1334
|
+
|
|
1335
|
+
Deterministic. ``None`` ``used_percent`` is treated as ``0.0`` so a
|
|
1336
|
+
freshly refreshed account ties with an unknown-usage account.
|
|
1337
|
+
|
|
1338
|
+
When two or more candidates share the same primary ``used_percent``,
|
|
1339
|
+
the account with the **higher** secondary (weekly) ``used_percent`` is
|
|
1340
|
+
preferred — i.e. the one with the least remaining weekly capacity.
|
|
1341
|
+
This drains the most-saturated account first and preserves the freshest
|
|
1342
|
+
one for later cycles, matching operator intent for "fill first" behavior.
|
|
1343
|
+
``account_id`` ascending is the final stable tiebreaker.
|
|
1344
|
+
|
|
1345
|
+
Drained accounts are only reachable here when no healthy or probing
|
|
1346
|
+
account exists, via the existing ``effective_pool`` ladder; this helper
|
|
1347
|
+
introduces no new bypass.
|
|
1348
|
+
"""
|
|
1349
|
+
return min(available, key=_fill_first_sort_key)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
# Minimum persisted cooldown for a metadata-free 429. The error-count backoff
|
|
1353
|
+
# starts near 0.2s, which is meaningless as a cross-replica cooldown: a peer
|
|
1354
|
+
# replica reading the row would flip it back to ACTIVE almost immediately. The
|
|
1355
|
+
# floor applies only to the persisted ``reset_at`` deadline written on the
|
|
1356
|
+
# backoff fallback path — Retry-After hints are persisted rounded up to the
|
|
1357
|
+
# next whole second (persistence truncates ``reset_at`` via ``int()``, so a
|
|
1358
|
+
# short or fractional hint must not round down to an already-elapsed
|
|
1359
|
+
# deadline), upstream reset metadata is persisted verbatim, and the local
|
|
1360
|
+
# ``cooldown_until`` keeps the raw backoff.
|
|
1361
|
+
RATE_LIMITED_MIN_COOLDOWN_SECONDS = 30.0
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def handle_rate_limit(state: AccountState, error: UpstreamError) -> None:
|
|
1365
|
+
now = time.time()
|
|
1366
|
+
state.status = AccountStatus.RATE_LIMITED
|
|
1367
|
+
state.error_count += 1
|
|
1368
|
+
state.last_error_at = now
|
|
1369
|
+
state.blocked_at = now
|
|
1370
|
+
|
|
1371
|
+
reset_at = _extract_reset_at(error, now=now)
|
|
1372
|
+
|
|
1373
|
+
message = error.get("message")
|
|
1374
|
+
delay = parse_retry_after(message) if message else None
|
|
1375
|
+
if delay is None:
|
|
1376
|
+
delay = backoff_seconds(state.error_count)
|
|
1377
|
+
persisted_delay = max(delay, RATE_LIMITED_MIN_COOLDOWN_SECONDS)
|
|
1378
|
+
else:
|
|
1379
|
+
persisted_delay = delay
|
|
1380
|
+
state.cooldown_until = now + delay
|
|
1381
|
+
|
|
1382
|
+
if reset_at is not None:
|
|
1383
|
+
state.reset_at = reset_at
|
|
1384
|
+
else:
|
|
1385
|
+
# Persist the resolved cooldown deadline so replicas sharing the
|
|
1386
|
+
# database honor it instead of flipping the account back to ACTIVE
|
|
1387
|
+
# from their own (empty) runtime state. The write rides the existing
|
|
1388
|
+
# ``mark_rate_limit -> _persist_state`` status update. Round the
|
|
1389
|
+
# deadline UP to a whole second: ``_persist_state`` truncates via
|
|
1390
|
+
# ``int()``, so a short or fractional Retry-After hint (for example
|
|
1391
|
+
# "500ms", or "1s" near a second boundary) would otherwise persist a
|
|
1392
|
+
# deadline that is already elapsed for peer replicas, dropping the
|
|
1393
|
+
# cooldown entirely.
|
|
1394
|
+
state.reset_at = float(math.ceil(now + persisted_delay))
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
QUOTA_EXCEEDED_COOLDOWN_SECONDS = 120.0
|
|
1398
|
+
|
|
1399
|
+
# Upper bound for the user-visible "Try again in {N}s" hint that
|
|
1400
|
+
# ``select_account`` surfaces when zero candidates are selectable. The clamp
|
|
1401
|
+
# protects clients from waiting the worst-case persisted ``reset_at`` after
|
|
1402
|
+
# OpenAI-side reset events that propagate lazily through ``/wham/usage`` (see
|
|
1403
|
+
# https://github.com/Soju06/codex-lb/issues/676). codex-lb's background usage
|
|
1404
|
+
# refresh runs every ``USAGE_REFRESH_INTERVAL_SECONDS`` (60 s) and the
|
|
1405
|
+
# per-status cooldowns are 120s, so a 300s ceiling lets clients reattempt
|
|
1406
|
+
# inside the auto-recovery window. The underlying ``AccountState.reset_at``
|
|
1407
|
+
# and ``AccountState.cooldown_until`` fields are not clamped.
|
|
1408
|
+
SELECTOR_RETRY_HINT_MAX_SECONDS = 300
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _format_retry_hint(wait_seconds: float) -> str:
|
|
1412
|
+
capped = min(max(0.0, wait_seconds), float(SELECTOR_RETRY_HINT_MAX_SECONDS))
|
|
1413
|
+
return f"Rate limit exceeded. Try again in {capped:.0f}s"
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def handle_quota_exceeded(state: AccountState, error: UpstreamError) -> None:
|
|
1417
|
+
now = time.time()
|
|
1418
|
+
state.status = AccountStatus.QUOTA_EXCEEDED
|
|
1419
|
+
state.used_percent = 100.0
|
|
1420
|
+
state.blocked_at = now
|
|
1421
|
+
state.cooldown_until = now + QUOTA_EXCEEDED_COOLDOWN_SECONDS
|
|
1422
|
+
|
|
1423
|
+
reset_at = _extract_reset_at(error, now=now)
|
|
1424
|
+
if reset_at is not None:
|
|
1425
|
+
state.reset_at = reset_at
|
|
1426
|
+
else:
|
|
1427
|
+
state.reset_at = int(now + 3600)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def handle_permanent_failure(state: AccountState, error_code: str) -> None:
|
|
1431
|
+
state.status = account_status_for_permanent_failure(error_code)
|
|
1432
|
+
state.deactivation_reason = PERMANENT_FAILURE_CODES.get(
|
|
1433
|
+
error_code,
|
|
1434
|
+
f"Authentication failed: {error_code}",
|
|
1435
|
+
)
|
|
1436
|
+
state.blocked_at = None
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def account_status_for_permanent_failure(error_code: str) -> AccountStatus:
|
|
1440
|
+
if error_code in REAUTH_REQUIRED_FAILURE_CODES:
|
|
1441
|
+
return AccountStatus.REAUTH_REQUIRED
|
|
1442
|
+
return AccountStatus.DEACTIVATED
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
FailoverAction = Literal["failover_next", "retry_same_account", "surface"]
|
|
1446
|
+
|
|
1447
|
+
# Owner-bound burst 429 (a code-less upstream HTTP 429 burst/concurrency
|
|
1448
|
+
# rejection on a request that cannot move to another account): bounded
|
|
1449
|
+
# same-account backoff before the original rejection is surfaced. Module
|
|
1450
|
+
# constants on purpose -- the Settings ratchet is full and this is a transport
|
|
1451
|
+
# invariant, not an operator knob. Waits are 1 s, 2 s, 4 s (upstream
|
|
1452
|
+
# ``Retry-After`` is a floor), never above ``BURST_SAME_ACCOUNT_MAX_WAIT_SECONDS``.
|
|
1453
|
+
BURST_SAME_ACCOUNT_MAX_RETRIES = 3
|
|
1454
|
+
BURST_SAME_ACCOUNT_BASE_SECONDS = 1.0
|
|
1455
|
+
BURST_SAME_ACCOUNT_MAX_WAIT_SECONDS = 10.0
|
|
1456
|
+
# ``Retry-After`` stamped on a surfaced burst 429 that carried none upstream;
|
|
1457
|
+
# matches ``app.core.resilience.overload.LOCAL_OVERLOAD_RETRY_AFTER_SECONDS``.
|
|
1458
|
+
BURST_SURFACE_RETRY_AFTER_SECONDS = 5
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def burst_same_account_backoff_seconds(retry_index: int, *, retry_after_seconds: float | None) -> float:
|
|
1462
|
+
"""Deterministic wait before same-account burst retry ``retry_index`` (1-based).
|
|
1463
|
+
|
|
1464
|
+
``min(MAX_WAIT, max(retry_after_seconds or 0, BASE * 2 ** (retry_index - 1)))``:
|
|
1465
|
+
exponential from ``BURST_SAME_ACCOUNT_BASE_SECONDS`` with the upstream
|
|
1466
|
+
``Retry-After`` (when present and positive) acting as a floor. No jitter, so
|
|
1467
|
+
the caller's timing seam (``scheduler.sleep``) fully owns the wait.
|
|
1468
|
+
"""
|
|
1469
|
+
exponential = BURST_SAME_ACCOUNT_BASE_SECONDS * (2 ** (max(retry_index, 1) - 1))
|
|
1470
|
+
floor = float(retry_after_seconds) if retry_after_seconds is not None and retry_after_seconds > 0 else 0.0
|
|
1471
|
+
return min(BURST_SAME_ACCOUNT_MAX_WAIT_SECONDS, max(floor, exponential))
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def failover_decision(
|
|
1475
|
+
*,
|
|
1476
|
+
failure_class: FailureClass,
|
|
1477
|
+
downstream_visible: bool,
|
|
1478
|
+
candidates_remaining: int,
|
|
1479
|
+
owner_bound: bool = False,
|
|
1480
|
+
same_account_retry_available: bool = False,
|
|
1481
|
+
) -> FailoverAction:
|
|
1482
|
+
"""Decide how a pre-visible upstream failure is handled.
|
|
1483
|
+
|
|
1484
|
+
``owner_bound`` means the request cannot move to another account (dispatched
|
|
1485
|
+
account-bound payload, required previous-response / turn-state / file
|
|
1486
|
+
owner). Such a request never fails over -- ``failover_next`` would be a
|
|
1487
|
+
lie -- so it either retries the same account (when the caller reports a
|
|
1488
|
+
bounded same-account retry is still available) or surfaces the failure.
|
|
1489
|
+
"""
|
|
1490
|
+
if downstream_visible:
|
|
1491
|
+
return "surface"
|
|
1492
|
+
if owner_bound:
|
|
1493
|
+
return (
|
|
1494
|
+
"retry_same_account"
|
|
1495
|
+
if same_account_retry_available and failure_class != "account_unavailable"
|
|
1496
|
+
else "surface"
|
|
1497
|
+
)
|
|
1498
|
+
if candidates_remaining <= 0:
|
|
1499
|
+
return "surface"
|
|
1500
|
+
if failure_class in ("rate_limit", "quota", "retryable_transient", "account_unavailable"):
|
|
1501
|
+
return "failover_next"
|
|
1502
|
+
return "surface"
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def plausible_rate_limit_reset_at(
|
|
1506
|
+
reset_at: int | float | None,
|
|
1507
|
+
*,
|
|
1508
|
+
now: float | None = None,
|
|
1509
|
+
allow_rounding_slack: bool = True,
|
|
1510
|
+
) -> float | None:
|
|
1511
|
+
"""Return a finite near-future reset deadline, or ``None`` when invalid."""
|
|
1512
|
+
|
|
1513
|
+
if reset_at is None or isinstance(reset_at, bool):
|
|
1514
|
+
return None
|
|
1515
|
+
current = time.time() if now is None else now
|
|
1516
|
+
if isinstance(reset_at, float) and not math.isfinite(reset_at):
|
|
1517
|
+
return None
|
|
1518
|
+
horizon = RATE_LIMIT_RESET_MAX_HORIZON_SECONDS
|
|
1519
|
+
if allow_rounding_slack:
|
|
1520
|
+
# Persisted deadlines are whole seconds; ceil may move an otherwise
|
|
1521
|
+
# valid raw deadline just beyond the metadata horizon.
|
|
1522
|
+
horizon += RATE_LIMIT_RESET_ROUNDING_TOLERANCE_SECONDS
|
|
1523
|
+
# Keep this comparison ahead of float conversion: Python integers can be
|
|
1524
|
+
# arbitrarily large, while converting malformed metadata can overflow.
|
|
1525
|
+
if reset_at <= current or reset_at > current + horizon:
|
|
1526
|
+
return None
|
|
1527
|
+
return float(reset_at)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _extract_reset_at(error: UpstreamError, *, now: float) -> int | None:
|
|
1531
|
+
reset_at = plausible_rate_limit_reset_at(
|
|
1532
|
+
error.get("resets_at"),
|
|
1533
|
+
now=now,
|
|
1534
|
+
allow_rounding_slack=False,
|
|
1535
|
+
)
|
|
1536
|
+
if reset_at is not None:
|
|
1537
|
+
rounded_reset_at = int(math.ceil(reset_at))
|
|
1538
|
+
if plausible_rate_limit_reset_at(rounded_reset_at, now=now) is not None:
|
|
1539
|
+
return rounded_reset_at
|
|
1540
|
+
return None
|
|
1541
|
+
|
|
1542
|
+
reset_in = error.get("resets_in_seconds")
|
|
1543
|
+
if reset_in is None or isinstance(reset_in, bool):
|
|
1544
|
+
return None
|
|
1545
|
+
if isinstance(reset_in, float) and not math.isfinite(reset_in):
|
|
1546
|
+
return None
|
|
1547
|
+
if reset_in <= 0 or reset_in > RATE_LIMIT_RESET_MAX_HORIZON_SECONDS:
|
|
1548
|
+
return None
|
|
1549
|
+
rounded_reset_at = int(math.ceil(now + float(reset_in)))
|
|
1550
|
+
if plausible_rate_limit_reset_at(rounded_reset_at, now=now) is not None:
|
|
1551
|
+
return rounded_reset_at
|
|
1552
|
+
return None
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def evaluate_health_tier(
|
|
1556
|
+
state: AccountState,
|
|
1557
|
+
*,
|
|
1558
|
+
now: float | None = None,
|
|
1559
|
+
drain_entered_at: float | None = None,
|
|
1560
|
+
probe_success_streak: int = 0,
|
|
1561
|
+
drain_primary_threshold_pct: float = DRAIN_PRIMARY_THRESHOLD_PCT,
|
|
1562
|
+
drain_secondary_threshold_pct: float = DRAIN_SECONDARY_THRESHOLD_PCT,
|
|
1563
|
+
drain_error_window_seconds: float = DRAIN_ERROR_WINDOW_SECONDS,
|
|
1564
|
+
drain_error_count_threshold: int = DRAIN_ERROR_COUNT_THRESHOLD,
|
|
1565
|
+
probe_quiet_seconds: float = PROBE_QUIET_SECONDS,
|
|
1566
|
+
probe_success_streak_required: int = PROBE_SUCCESS_STREAK_REQUIRED,
|
|
1567
|
+
) -> int:
|
|
1568
|
+
current = now or time.time()
|
|
1569
|
+
|
|
1570
|
+
if state.status in (
|
|
1571
|
+
AccountStatus.RATE_LIMITED,
|
|
1572
|
+
AccountStatus.QUOTA_EXCEEDED,
|
|
1573
|
+
AccountStatus.PAUSED,
|
|
1574
|
+
AccountStatus.DEACTIVATED,
|
|
1575
|
+
):
|
|
1576
|
+
return state.health_tier
|
|
1577
|
+
|
|
1578
|
+
should_drain = False
|
|
1579
|
+
|
|
1580
|
+
if state.used_percent is not None and state.used_percent >= drain_primary_threshold_pct:
|
|
1581
|
+
should_drain = True
|
|
1582
|
+
|
|
1583
|
+
if state.secondary_used_percent is not None and state.secondary_used_percent >= drain_secondary_threshold_pct:
|
|
1584
|
+
should_drain = True
|
|
1585
|
+
|
|
1586
|
+
if (
|
|
1587
|
+
state.error_count >= drain_error_count_threshold
|
|
1588
|
+
and state.last_error_at is not None
|
|
1589
|
+
and current - state.last_error_at < drain_error_window_seconds
|
|
1590
|
+
):
|
|
1591
|
+
should_drain = True
|
|
1592
|
+
|
|
1593
|
+
current_tier = state.health_tier
|
|
1594
|
+
|
|
1595
|
+
if current_tier == HEALTH_TIER_HEALTHY:
|
|
1596
|
+
return HEALTH_TIER_DRAINING if should_drain else HEALTH_TIER_HEALTHY
|
|
1597
|
+
|
|
1598
|
+
if current_tier == HEALTH_TIER_DRAINING:
|
|
1599
|
+
if should_drain:
|
|
1600
|
+
return HEALTH_TIER_DRAINING
|
|
1601
|
+
if drain_entered_at is not None and current - drain_entered_at >= probe_quiet_seconds:
|
|
1602
|
+
return HEALTH_TIER_PROBING
|
|
1603
|
+
return HEALTH_TIER_DRAINING
|
|
1604
|
+
|
|
1605
|
+
if current_tier == HEALTH_TIER_PROBING:
|
|
1606
|
+
if should_drain:
|
|
1607
|
+
return HEALTH_TIER_DRAINING
|
|
1608
|
+
if probe_success_streak >= probe_success_streak_required:
|
|
1609
|
+
return HEALTH_TIER_HEALTHY
|
|
1610
|
+
return HEALTH_TIER_PROBING
|
|
1611
|
+
|
|
1612
|
+
return HEALTH_TIER_HEALTHY
|