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,1439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Coroutine, Mapping
|
|
9
|
+
from contextlib import AbstractAsyncContextManager
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from hashlib import sha256
|
|
12
|
+
from typing import Any, Protocol, TypeAlias
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.exc import OperationalError
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from app.core.auth import DEFAULT_PLAN, OpenAIAuthClaims, extract_id_token_claims
|
|
19
|
+
from app.core.auth.refresh import (
|
|
20
|
+
TOKEN_REFRESH_TIMEOUT_SECONDS,
|
|
21
|
+
RefreshError,
|
|
22
|
+
TokenRefreshResult,
|
|
23
|
+
get_token_refresh_timeout_override,
|
|
24
|
+
pop_token_refresh_timeout_override,
|
|
25
|
+
push_token_refresh_timeout_override,
|
|
26
|
+
refresh_access_token,
|
|
27
|
+
should_refresh,
|
|
28
|
+
)
|
|
29
|
+
from app.core.balancer import PERMANENT_FAILURE_CODES, account_status_for_permanent_failure
|
|
30
|
+
from app.core.crypto import TokenEncryptor
|
|
31
|
+
from app.core.plan_types import coerce_account_plan_type
|
|
32
|
+
from app.core.upstream_proxy import UpstreamProxyRouteError, resolve_upstream_route
|
|
33
|
+
from app.core.utils.shared_future import wait_on_shared_future
|
|
34
|
+
from app.core.utils.time import utcnow
|
|
35
|
+
from app.db.models import Account, AccountProxyBinding, AccountStatus
|
|
36
|
+
from app.db.session import get_background_session
|
|
37
|
+
from app.modules.accounts.refresh_claims import RefreshClaimCoordinatorPort, get_refresh_claim_coordinator
|
|
38
|
+
from app.modules.proxy.account_cache import get_account_selection_cache, mark_account_routing_unavailable
|
|
39
|
+
from app.modules.proxy.work_admission import ADMISSION_WAIT_TIMEOUT_SECONDS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AccountsRepositoryPort(Protocol):
|
|
43
|
+
async def get_by_id(self, account_id: str) -> Account | None: ...
|
|
44
|
+
|
|
45
|
+
async def get_by_id_fresh(self, account_id: str) -> Account | None: ...
|
|
46
|
+
|
|
47
|
+
async def update_status(
|
|
48
|
+
self,
|
|
49
|
+
account_id: str,
|
|
50
|
+
status: AccountStatus,
|
|
51
|
+
deactivation_reason: str | None = None,
|
|
52
|
+
reset_at: int | None = None,
|
|
53
|
+
blocked_at: int | None = None,
|
|
54
|
+
) -> bool: ...
|
|
55
|
+
|
|
56
|
+
async def update_status_if_current(
|
|
57
|
+
self,
|
|
58
|
+
account_id: str,
|
|
59
|
+
status: AccountStatus,
|
|
60
|
+
deactivation_reason: str | None = None,
|
|
61
|
+
reset_at: int | None = None,
|
|
62
|
+
*,
|
|
63
|
+
expected_status: AccountStatus,
|
|
64
|
+
expected_deactivation_reason: str | None = None,
|
|
65
|
+
expected_reset_at: int | None = None,
|
|
66
|
+
expected_refresh_token_encrypted: bytes | None = None,
|
|
67
|
+
) -> bool: ...
|
|
68
|
+
|
|
69
|
+
async def rotate_tokens(
|
|
70
|
+
self,
|
|
71
|
+
account_id: str,
|
|
72
|
+
access_token_encrypted: bytes,
|
|
73
|
+
refresh_token_encrypted: bytes,
|
|
74
|
+
id_token_encrypted: bytes,
|
|
75
|
+
last_refresh: datetime,
|
|
76
|
+
*,
|
|
77
|
+
expected_refresh_token_encrypted: bytes,
|
|
78
|
+
plan_type: str | None = None,
|
|
79
|
+
email: str | None = None,
|
|
80
|
+
chatgpt_account_id: str | None = None,
|
|
81
|
+
chatgpt_user_id: str | None = None,
|
|
82
|
+
workspace_id: str | None = None,
|
|
83
|
+
workspace_label: str | None = None,
|
|
84
|
+
seat_type: str | None = None,
|
|
85
|
+
) -> bool: ...
|
|
86
|
+
|
|
87
|
+
async def update_account_metadata(
|
|
88
|
+
self,
|
|
89
|
+
account_id: str,
|
|
90
|
+
*,
|
|
91
|
+
plan_type: str | None = None,
|
|
92
|
+
email: str | None = None,
|
|
93
|
+
chatgpt_account_id: str | None = None,
|
|
94
|
+
chatgpt_user_id: str | None = None,
|
|
95
|
+
workspace_id: str | None = None,
|
|
96
|
+
workspace_label: str | None = None,
|
|
97
|
+
seat_type: str | None = None,
|
|
98
|
+
last_refresh: datetime | None = None,
|
|
99
|
+
) -> bool: ...
|
|
100
|
+
|
|
101
|
+
async def workspace_slot_taken(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
account_id: str,
|
|
105
|
+
email: str,
|
|
106
|
+
chatgpt_account_id: str | None,
|
|
107
|
+
workspace_id: str,
|
|
108
|
+
) -> bool: ...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RefreshAdmissionLeasePort(Protocol):
|
|
112
|
+
def release(self) -> None: ...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
logger = logging.getLogger(__name__)
|
|
116
|
+
|
|
117
|
+
# Bound on how many times a successful refresh retries its token compare-and-set
|
|
118
|
+
# against freshly observed ciphertext when a concurrent re-auth/import merely
|
|
119
|
+
# re-encrypts the same refresh-token plaintext (Fernet ciphertext is
|
|
120
|
+
# non-deterministic, so the same plaintext yields different bytes). Genuine
|
|
121
|
+
# newer rotations are adopted instead of retried, so this only guards the rare
|
|
122
|
+
# same-plaintext re-encryption race and must stay small to avoid live-locking.
|
|
123
|
+
_TOKEN_CAS_MAX_ATTEMPTS = 5
|
|
124
|
+
|
|
125
|
+
# Dedicated bounded retry budget for the FINAL safety persist of a freshly
|
|
126
|
+
# rotated token, DELIBERATELY SEPARATE from the claim/caller deadline. Persisting
|
|
127
|
+
# a valid single-use rotated token is worth a few extra milliseconds over budget:
|
|
128
|
+
# giving up here would strand the account holding the already-consumed token, and
|
|
129
|
+
# a later blind retry would re-exchange that consumed token into an
|
|
130
|
+
# ``invalid_grant``/reauth knockout of an otherwise healthy account. Each attempt
|
|
131
|
+
# is still a ciphertext-guarded compare-and-set against the freshly re-read
|
|
132
|
+
# ciphertext (a genuinely different peer plaintext is ADOPTED, never clobbered);
|
|
133
|
+
# because any ciphertext change means a writer committed and no realistic writer
|
|
134
|
+
# re-encrypts the SAME consumed token in a tight loop, this lands within a couple
|
|
135
|
+
# of attempts in practice. Kept small (with tiny backoff) so a truly pathological
|
|
136
|
+
# storm cannot live-lock.
|
|
137
|
+
_FINAL_PERSIST_MAX_ATTEMPTS = 3
|
|
138
|
+
_FINAL_PERSIST_RETRY_BASE_SECONDS = 0.02
|
|
139
|
+
|
|
140
|
+
# Bound on how many times releasing a refresh claim is retried when the release
|
|
141
|
+
# DELETE hits a transient DB error (SQLite lock past the busy timeout, a dropped
|
|
142
|
+
# Postgres connection). Release runs in ``finally`` after the token update has
|
|
143
|
+
# already committed, so a release failure must never mask a successful refresh;
|
|
144
|
+
# after a couple of brief retries we log and swallow and let the claim expire by
|
|
145
|
+
# its TTL.
|
|
146
|
+
_CLAIM_RELEASE_MAX_ATTEMPTS = 3
|
|
147
|
+
_CLAIM_RELEASE_RETRY_BASE_SECONDS = 0.05
|
|
148
|
+
|
|
149
|
+
# Cross-replica refresh-claim wait/poll tuning (fixed; issue #1340 /
|
|
150
|
+
# PRINCIPLES.md P2). The wait caps how long a non-claimant polls for the claim
|
|
151
|
+
# winner's rotated tokens before giving up; the poll interval bounds
|
|
152
|
+
# claim-table read pressure while waiting.
|
|
153
|
+
_TOKEN_REFRESH_CLAIM_WAIT_SECONDS = 8.0
|
|
154
|
+
_TOKEN_REFRESH_CLAIM_POLL_SECONDS = 0.25
|
|
155
|
+
# Cross-replica token-refresh claim TTL (account_refresh_claims table). The
|
|
156
|
+
# claim is acquired BEFORE the refresh-admission wait and held through the
|
|
157
|
+
# OAuth exchange, so the TTL must cover both: the admission wait ceiling plus
|
|
158
|
+
# the HTTP exchange (2x for margin). A TTL sized only around the HTTP timeout
|
|
159
|
+
# could expire under a healthy claimant stuck in admission, letting another
|
|
160
|
+
# replica claim the same account and reuse the single-use refresh token. The
|
|
161
|
+
# 30 s floor bounds how long a crashed claimant can block refresh for one
|
|
162
|
+
# account.
|
|
163
|
+
_TOKEN_REFRESH_CLAIM_TTL_FLOOR_SECONDS = 30.0
|
|
164
|
+
# Negative cache for a failed refresh so a burst of requests for one account
|
|
165
|
+
# does not re-run the same failing exchange (transport errors are exempt).
|
|
166
|
+
_REFRESH_FAILURE_COOLDOWN_SECONDS = 5.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _token_refresh_claim_ttl_seconds() -> float:
|
|
170
|
+
return max(
|
|
171
|
+
_TOKEN_REFRESH_CLAIM_TTL_FLOOR_SECONDS,
|
|
172
|
+
ADMISSION_WAIT_TIMEOUT_SECONDS + 2.0 * TOKEN_REFRESH_TIMEOUT_SECONDS,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Terminal account statuses a PRIOR claim holder may have committed while
|
|
177
|
+
# leaving ``refresh_token_encrypted`` UNCHANGED: a permanent refresh failure
|
|
178
|
+
# (e.g. ``invalid_grant``) downgraded through ``_handle_permanent_refresh_failure``
|
|
179
|
+
# or the safe-terminal persist-conflict path (``_flag_persist_conflict_reauth``),
|
|
180
|
+
# both of which flag REAUTH_REQUIRED/DEACTIVATED without rotating the token. A
|
|
181
|
+
# waiter that wins the RELEASED claim and re-reads one of these on the SAME
|
|
182
|
+
# (unchanged) refresh material must NOT re-exchange the stored consumed/dead
|
|
183
|
+
# token; it must fail closed and surface the terminal state instead.
|
|
184
|
+
_TERMINAL_REFRESH_STATUSES = frozenset({AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_RefreshSingleflightKey: TypeAlias = tuple[str, str]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _RefreshSingleflight:
|
|
191
|
+
def __init__(self) -> None:
|
|
192
|
+
self._inflight: dict[_RefreshSingleflightKey, asyncio.Task[Account]] = {}
|
|
193
|
+
self._recent_failures: dict[_RefreshSingleflightKey, tuple[float, tuple[str, str, bool]]] = {}
|
|
194
|
+
self._lock = asyncio.Lock()
|
|
195
|
+
|
|
196
|
+
async def run(
|
|
197
|
+
self,
|
|
198
|
+
key: _RefreshSingleflightKey,
|
|
199
|
+
factory: Callable[[], Coroutine[object, object, Account]],
|
|
200
|
+
) -> Account:
|
|
201
|
+
account_id = key[0]
|
|
202
|
+
async with self._lock:
|
|
203
|
+
self._purge_stale_versions(account_id, keep_key=key)
|
|
204
|
+
cached_failure = self._recent_failures.get(key)
|
|
205
|
+
if cached_failure is not None:
|
|
206
|
+
expires_at, failure = cached_failure
|
|
207
|
+
if expires_at > time.monotonic():
|
|
208
|
+
code, message, is_permanent = failure
|
|
209
|
+
raise RefreshError(code, message, is_permanent)
|
|
210
|
+
self._recent_failures.pop(key, None)
|
|
211
|
+
task = self._inflight.get(key)
|
|
212
|
+
if task is not None and task.done() and not task.cancelled() and task.exception() is None:
|
|
213
|
+
pass
|
|
214
|
+
elif task is None or task.done():
|
|
215
|
+
task = asyncio.create_task(factory())
|
|
216
|
+
self._inflight[key] = task
|
|
217
|
+
task.add_done_callback(lambda done, *, cache_key=key: self._schedule_complete(cache_key, done))
|
|
218
|
+
assert task is not None
|
|
219
|
+
# Not asyncio.shield: shield attaches per-waiter callbacks to the
|
|
220
|
+
# shared singleflight task, which degrades to O(N^2) removal scans
|
|
221
|
+
# when piled-up waiters are cancelled (see shared_future.py). The
|
|
222
|
+
# helper preserves shield semantics: a cancelled waiter detaches
|
|
223
|
+
# without aborting the refresh.
|
|
224
|
+
return await wait_on_shared_future(task)
|
|
225
|
+
|
|
226
|
+
def _schedule_complete(self, key: _RefreshSingleflightKey, task: asyncio.Task[Account]) -> None:
|
|
227
|
+
asyncio.create_task(self._complete(key, task))
|
|
228
|
+
|
|
229
|
+
async def _complete(self, key: _RefreshSingleflightKey, task: asyncio.Task[Account]) -> None:
|
|
230
|
+
try:
|
|
231
|
+
async with self._lock:
|
|
232
|
+
current = self._inflight.get(key)
|
|
233
|
+
if current is not task:
|
|
234
|
+
# A successor owns settlement for this key; consume the
|
|
235
|
+
# stale task's result without touching its cache state.
|
|
236
|
+
if not task.cancelled():
|
|
237
|
+
task.exception()
|
|
238
|
+
return
|
|
239
|
+
self._inflight.pop(key, None)
|
|
240
|
+
if task.cancelled():
|
|
241
|
+
self._recent_failures.pop(key, None)
|
|
242
|
+
return
|
|
243
|
+
try:
|
|
244
|
+
task.result()
|
|
245
|
+
except RefreshError as exc:
|
|
246
|
+
ttl = _REFRESH_FAILURE_COOLDOWN_SECONDS
|
|
247
|
+
if ttl > 0 and not exc.transport_error:
|
|
248
|
+
self._recent_failures[key] = (
|
|
249
|
+
time.monotonic() + ttl,
|
|
250
|
+
(exc.code, exc.message, exc.is_permanent),
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
self._recent_failures.pop(key, None)
|
|
254
|
+
except BaseException:
|
|
255
|
+
self._recent_failures.pop(key, None)
|
|
256
|
+
else:
|
|
257
|
+
self._recent_failures.pop(key, None)
|
|
258
|
+
except BaseException:
|
|
259
|
+
# A singleflight task may serve both ordinary and private requests.
|
|
260
|
+
# Its completion callback therefore has no request-local privacy
|
|
261
|
+
# policy and must keep diagnostics content-free.
|
|
262
|
+
logger.error("Refresh singleflight completion cleanup failed")
|
|
263
|
+
|
|
264
|
+
def _purge_stale_versions(self, account_id: str, *, keep_key: _RefreshSingleflightKey) -> None:
|
|
265
|
+
stale_failures = [key for key in self._recent_failures if key[0] == account_id and key != keep_key]
|
|
266
|
+
for key in stale_failures:
|
|
267
|
+
self._recent_failures.pop(key, None)
|
|
268
|
+
stale_inflight = [
|
|
269
|
+
key for key, task in self._inflight.items() if key[0] == account_id and key != keep_key and task.done()
|
|
270
|
+
]
|
|
271
|
+
for key in stale_inflight:
|
|
272
|
+
self._inflight.pop(key, None)
|
|
273
|
+
|
|
274
|
+
def clear(self) -> None:
|
|
275
|
+
self._inflight.clear()
|
|
276
|
+
self._recent_failures.clear()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
_REFRESH_SINGLEFLIGHT = _RefreshSingleflight()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class AuthManager:
|
|
283
|
+
def __init__(
|
|
284
|
+
self,
|
|
285
|
+
repo: AccountsRepositoryPort,
|
|
286
|
+
*,
|
|
287
|
+
acquire_refresh_admission: Callable[[], Awaitable[RefreshAdmissionLeasePort]] | None = None,
|
|
288
|
+
refresh_repo_factory: Callable[[], AbstractAsyncContextManager[AccountsRepositoryPort]] | None = None,
|
|
289
|
+
refresh_claims: RefreshClaimCoordinatorPort | None = None,
|
|
290
|
+
redact_sensitive_details: bool = False,
|
|
291
|
+
) -> None:
|
|
292
|
+
self._repo = repo
|
|
293
|
+
self._encryptor = TokenEncryptor()
|
|
294
|
+
self._acquire_refresh_admission = acquire_refresh_admission
|
|
295
|
+
self._redact_sensitive_details = redact_sensitive_details
|
|
296
|
+
# Optional factory yielding a *fresh* accounts repo (own DB session) for
|
|
297
|
+
# the detached, shielded refresh task. When set, the singleflight body
|
|
298
|
+
# runs against this session instead of the request-scoped `repo`, so a
|
|
299
|
+
# caller cancelled by a client disconnect cannot close the session out
|
|
300
|
+
# from under the still-running refresh task and strand a pooled
|
|
301
|
+
# connection. See _run_refresh.
|
|
302
|
+
self._refresh_repo_factory = refresh_repo_factory
|
|
303
|
+
# Cross-replica refresh claim coordinator. ``None`` defers to the
|
|
304
|
+
# process default (see refresh_claims.get_refresh_claim_coordinator),
|
|
305
|
+
# which the test harness may set to ``None`` to disable claims.
|
|
306
|
+
self._refresh_claims = refresh_claims
|
|
307
|
+
|
|
308
|
+
async def ensure_fresh(self, account: Account, *, force: bool = False) -> Account:
|
|
309
|
+
if force or (account.status != AccountStatus.REAUTH_REQUIRED and should_refresh(account.last_refresh)):
|
|
310
|
+
account = await _REFRESH_SINGLEFLIGHT.run(
|
|
311
|
+
_refresh_singleflight_key(self._encryptor, account),
|
|
312
|
+
lambda: self._run_refresh(account),
|
|
313
|
+
)
|
|
314
|
+
return await self._ensure_chatgpt_account_id(account)
|
|
315
|
+
|
|
316
|
+
async def _run_refresh(self, account: Account) -> Account:
|
|
317
|
+
"""Singleflight body for token refresh.
|
|
318
|
+
|
|
319
|
+
Runs inside a detached task that the singleflight keeps alive via
|
|
320
|
+
``wait_on_shared_future`` (so concurrent waiters share one refresh and
|
|
321
|
+
a cancelled waiter does not abort it). Because the task outlives the
|
|
322
|
+
caller, it MUST NOT use the caller's request-scoped session: when a
|
|
323
|
+
client disconnects, the caller is cancelled and its
|
|
324
|
+
``async with get_background_session()`` closes that session, while this
|
|
325
|
+
shielded task keeps running and would then touch a closed,
|
|
326
|
+
concurrently-finalized ``AsyncSession`` (not safe for concurrent use) —
|
|
327
|
+
stranding a pooled connection that never returns. When a
|
|
328
|
+
``refresh_repo_factory`` is provided, open a fresh session here so the
|
|
329
|
+
refresh write is fully self-contained; otherwise fall back to the bound
|
|
330
|
+
repo (callers whose session is not client-cancellable, e.g. the usage
|
|
331
|
+
refresh scheduler).
|
|
332
|
+
"""
|
|
333
|
+
# The process-global singleflight can be joined by an ordinary request
|
|
334
|
+
# and a private realtime request in either order. Task-internal logging
|
|
335
|
+
# cannot safely inherit the first caller's policy, so shared refresh
|
|
336
|
+
# work always uses the strict, content-free diagnostic mode. Per-caller
|
|
337
|
+
# work after the shared task (for example the account-id metadata
|
|
338
|
+
# backfill) retains the caller's explicit policy.
|
|
339
|
+
if self._refresh_repo_factory is None:
|
|
340
|
+
owned = AuthManager(
|
|
341
|
+
self._repo,
|
|
342
|
+
acquire_refresh_admission=self._acquire_refresh_admission,
|
|
343
|
+
refresh_claims=self._refresh_claims,
|
|
344
|
+
redact_sensitive_details=True,
|
|
345
|
+
)
|
|
346
|
+
return await owned.refresh_account(account)
|
|
347
|
+
async with self._refresh_repo_factory() as repo:
|
|
348
|
+
owned = AuthManager(
|
|
349
|
+
repo,
|
|
350
|
+
acquire_refresh_admission=self._acquire_refresh_admission,
|
|
351
|
+
refresh_claims=self._refresh_claims,
|
|
352
|
+
redact_sensitive_details=True,
|
|
353
|
+
)
|
|
354
|
+
return await owned.refresh_account(account)
|
|
355
|
+
|
|
356
|
+
def _diagnostic_value(self, value: str | None) -> str | None:
|
|
357
|
+
if self._redact_sensitive_details and value is not None:
|
|
358
|
+
return "<redacted>"
|
|
359
|
+
return value
|
|
360
|
+
|
|
361
|
+
async def refresh_account(self, account: Account) -> Account:
|
|
362
|
+
claims = self._refresh_claims if self._refresh_claims is not None else get_refresh_claim_coordinator()
|
|
363
|
+
if claims is None:
|
|
364
|
+
requested_fingerprint = _refresh_token_material_fingerprint(
|
|
365
|
+
self._encryptor,
|
|
366
|
+
account.refresh_token_encrypted,
|
|
367
|
+
)
|
|
368
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
369
|
+
if latest is not None:
|
|
370
|
+
if (
|
|
371
|
+
_refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
|
|
372
|
+
!= requested_fingerprint
|
|
373
|
+
):
|
|
374
|
+
return _adopt_account_row(account, latest)
|
|
375
|
+
_adopt_account_row(account, latest)
|
|
376
|
+
if account.status in _TERMINAL_REFRESH_STATUSES:
|
|
377
|
+
raise _terminal_status_refresh_error(account)
|
|
378
|
+
return await self._perform_refresh(account, refresh_token_encrypted=account.refresh_token_encrypted)
|
|
379
|
+
return await self._refresh_account_with_claim(account, claims)
|
|
380
|
+
|
|
381
|
+
async def _refresh_account_with_claim(
|
|
382
|
+
self,
|
|
383
|
+
account: Account,
|
|
384
|
+
claims: RefreshClaimCoordinatorPort,
|
|
385
|
+
) -> Account:
|
|
386
|
+
"""Serialize the upstream token exchange across replicas.
|
|
387
|
+
|
|
388
|
+
Exactly one claimant per account may run the OAuth exchange at a time;
|
|
389
|
+
everyone else waits (bounded) for the winner's rotated tokens to land
|
|
390
|
+
and adopts them without an upstream call. Refresh tokens are single-use
|
|
391
|
+
upstream, so a second concurrent exchange would receive a permanent
|
|
392
|
+
``refresh_token_reused`` error and could revoke the token family.
|
|
393
|
+
"""
|
|
394
|
+
requested_fingerprint = _refresh_token_material_fingerprint(
|
|
395
|
+
self._encryptor,
|
|
396
|
+
account.refresh_token_encrypted,
|
|
397
|
+
)
|
|
398
|
+
# The wait for a foreign claim is bounded by the fixed cap AND the
|
|
399
|
+
# caller's remaining refresh budget: the singleflight body is shielded
|
|
400
|
+
# and outlives a cancelled caller, so without the budget cap a small
|
|
401
|
+
# request budget with a held foreign claim would leave this task
|
|
402
|
+
# polling for the full fixed wait (holding its repo session and
|
|
403
|
+
# the inflight singleflight entry that later callers join).
|
|
404
|
+
wait_seconds = _TOKEN_REFRESH_CLAIM_WAIT_SECONDS
|
|
405
|
+
caller_budget = get_token_refresh_timeout_override()
|
|
406
|
+
start = time.monotonic()
|
|
407
|
+
# Absolute deadline of the caller's ORIGINAL refresh budget (if any).
|
|
408
|
+
# Used to cap the post-wait upstream exchange so a long claim wait
|
|
409
|
+
# cannot be followed by a full-budget OAuth exchange that overruns the
|
|
410
|
+
# request deadline.
|
|
411
|
+
caller_deadline: float | None = None
|
|
412
|
+
if caller_budget is not None:
|
|
413
|
+
caller_budget = max(0.0, caller_budget)
|
|
414
|
+
caller_deadline = start + caller_budget
|
|
415
|
+
wait_seconds = min(wait_seconds, caller_budget)
|
|
416
|
+
deadline = start + wait_seconds
|
|
417
|
+
poll_seconds = _TOKEN_REFRESH_CLAIM_POLL_SECONDS
|
|
418
|
+
# NOTE: comparisons below use the fingerprint captured at entry, not
|
|
419
|
+
# ``account.refresh_token_encrypted``: when ``account`` is attached to
|
|
420
|
+
# the repo's session, ``get_by_id_fresh`` refreshes that very
|
|
421
|
+
# identity-map object in place, so comparing against the live attribute
|
|
422
|
+
# would compare the row with itself.
|
|
423
|
+
while True:
|
|
424
|
+
if await claims.try_acquire(
|
|
425
|
+
account.id,
|
|
426
|
+
ttl_seconds=_token_refresh_claim_ttl_seconds(),
|
|
427
|
+
owner=requested_fingerprint,
|
|
428
|
+
):
|
|
429
|
+
# Monotonic deadline covering the ENTIRE claim hold from this
|
|
430
|
+
# point: the exchange AND the post-exchange DB persist/status-CAS
|
|
431
|
+
# loops all run while this claim is held. The exchange itself is
|
|
432
|
+
# already budget-bounded, but the persist/status write loops were
|
|
433
|
+
# bounded only by attempt count -- a contended DB write could keep
|
|
434
|
+
# them looping past the claim TTL, after which a peer can win the
|
|
435
|
+
# claim and re-exchange the (now consumed) single-use token. Bound
|
|
436
|
+
# the persist section by the smaller of the claim TTL and the
|
|
437
|
+
# caller's remaining budget so total claim-hold stays within
|
|
438
|
+
# budget + a small fixed release, and a persist that runs past the
|
|
439
|
+
# deadline stops (releasing the claim) instead of looping.
|
|
440
|
+
claim_ttl = _token_refresh_claim_ttl_seconds()
|
|
441
|
+
persist_deadline = time.monotonic() + claim_ttl
|
|
442
|
+
if caller_deadline is not None:
|
|
443
|
+
persist_deadline = min(persist_deadline, caller_deadline)
|
|
444
|
+
try:
|
|
445
|
+
# Post-claim fresh re-read: another replica may have rotated
|
|
446
|
+
# the material between the caller's read and our claim.
|
|
447
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
448
|
+
if latest is not None:
|
|
449
|
+
if (
|
|
450
|
+
_refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
|
|
451
|
+
!= requested_fingerprint
|
|
452
|
+
):
|
|
453
|
+
# A peer genuinely rotated/repaired the material; adopt
|
|
454
|
+
# it and proceed without an upstream call.
|
|
455
|
+
return _adopt_account_row(account, latest)
|
|
456
|
+
if latest.status in _TERMINAL_REFRESH_STATUSES:
|
|
457
|
+
# A PRIOR claim holder finished by committing a terminal
|
|
458
|
+
# status (a permanent ``invalid_grant`` downgrade, or the
|
|
459
|
+
# safe-terminal persist-conflict path) WITHOUT rotating
|
|
460
|
+
# ``refresh_token_encrypted``. The fresh row therefore
|
|
461
|
+
# still holds the SAME consumed/dead token: re-exchanging
|
|
462
|
+
# it would just repeat a permanent refresh failure and
|
|
463
|
+
# defeat the fail-closed behavior the terminal flag exists
|
|
464
|
+
# to enforce. Adopt the terminal row (so the caller's
|
|
465
|
+
# object mirrors the committed state) and surface it as a
|
|
466
|
+
# PERMANENT failure instead of starting another upstream
|
|
467
|
+
# exchange. A GENUINE peer rotation is handled by the
|
|
468
|
+
# fingerprint branch above, so a repaired account never
|
|
469
|
+
# reaches here.
|
|
470
|
+
_adopt_account_row(account, latest)
|
|
471
|
+
raise _terminal_status_refresh_error(account)
|
|
472
|
+
fresh_material = (
|
|
473
|
+
latest.refresh_token_encrypted if latest is not None else account.refresh_token_encrypted
|
|
474
|
+
)
|
|
475
|
+
# The claim wait may have consumed most/all of the caller's
|
|
476
|
+
# refresh budget. This singleflight body is shielded from
|
|
477
|
+
# caller cancellation, so proceeding with the ORIGINAL
|
|
478
|
+
# ``token_refresh_timeout_override`` still active would let the
|
|
479
|
+
# OAuth exchange spend a whole budget AGAIN — overrunning the
|
|
480
|
+
# request deadline while pinning this repo session and the
|
|
481
|
+
# inflight singleflight entry that later callers join.
|
|
482
|
+
# Recompute the remaining budget: fail fast with the transient
|
|
483
|
+
# claim timeout when nothing is left, otherwise cap the
|
|
484
|
+
# exchange to what remains of the caller's deadline.
|
|
485
|
+
if caller_deadline is not None:
|
|
486
|
+
remaining_budget = caller_deadline - time.monotonic()
|
|
487
|
+
if remaining_budget <= 0:
|
|
488
|
+
raise RefreshError(
|
|
489
|
+
"refresh_claim_timeout",
|
|
490
|
+
f"Token refresh for account {account.id} exhausted its "
|
|
491
|
+
f"{caller_budget:.3f}s budget waiting for a peer replica's refresh "
|
|
492
|
+
f"claim before the upstream exchange could start",
|
|
493
|
+
False,
|
|
494
|
+
transport_error=True,
|
|
495
|
+
)
|
|
496
|
+
override_token = push_token_refresh_timeout_override(remaining_budget)
|
|
497
|
+
try:
|
|
498
|
+
return await self._perform_refresh(
|
|
499
|
+
account,
|
|
500
|
+
refresh_token_encrypted=fresh_material,
|
|
501
|
+
deadline=persist_deadline,
|
|
502
|
+
)
|
|
503
|
+
finally:
|
|
504
|
+
pop_token_refresh_timeout_override(override_token)
|
|
505
|
+
return await self._perform_refresh(
|
|
506
|
+
account,
|
|
507
|
+
refresh_token_encrypted=fresh_material,
|
|
508
|
+
deadline=persist_deadline,
|
|
509
|
+
)
|
|
510
|
+
finally:
|
|
511
|
+
await self._release_claim_quietly(claims, account.id, owner=requested_fingerprint)
|
|
512
|
+
# Claim held by another replica: adopt its rotation as soon as it
|
|
513
|
+
# commits; never write account status from the losing side.
|
|
514
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
515
|
+
if latest is not None and (
|
|
516
|
+
_refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
|
|
517
|
+
!= requested_fingerprint
|
|
518
|
+
):
|
|
519
|
+
return _adopt_account_row(account, latest)
|
|
520
|
+
remaining = deadline - time.monotonic()
|
|
521
|
+
if remaining <= 0:
|
|
522
|
+
raise RefreshError(
|
|
523
|
+
"refresh_claim_timeout",
|
|
524
|
+
f"Token refresh for account {account.id} is claimed by another replica; "
|
|
525
|
+
f"timed out waiting {wait_seconds:.3f}s for its rotation",
|
|
526
|
+
False,
|
|
527
|
+
transport_error=True,
|
|
528
|
+
)
|
|
529
|
+
# Cap the per-iteration sleep to the remaining claim-wait budget.
|
|
530
|
+
# The configured poll interval may exceed what is left of the
|
|
531
|
+
# caller's deadline; sleeping the full interval would let this
|
|
532
|
+
# shielded task overrun the caller budget while still holding its
|
|
533
|
+
# repo session and the inflight singleflight entry that later
|
|
534
|
+
# callers join. Sleep the smaller of the poll interval and the time
|
|
535
|
+
# remaining so the claim wait is truly bounded by the caller budget.
|
|
536
|
+
await asyncio.sleep(min(poll_seconds, remaining))
|
|
537
|
+
|
|
538
|
+
async def _release_claim_quietly(
|
|
539
|
+
self,
|
|
540
|
+
claims: RefreshClaimCoordinatorPort,
|
|
541
|
+
account_id: str,
|
|
542
|
+
*,
|
|
543
|
+
owner: str,
|
|
544
|
+
) -> None:
|
|
545
|
+
"""Release the refresh claim without ever masking the refresh result.
|
|
546
|
+
|
|
547
|
+
The release runs in ``finally`` after the token update has already
|
|
548
|
+
committed, so a transient DB error here (a SQLite lock past the busy
|
|
549
|
+
timeout, a dropped Postgres connection) MUST NOT replace a successful
|
|
550
|
+
``_perform_refresh``/adoption return value with a failure and leave the
|
|
551
|
+
claim blocking peers until its TTL: the committed rotation is already
|
|
552
|
+
durable and the claim harmlessly expires on its own. So retry a couple
|
|
553
|
+
of times for a transient hiccup, then log and swallow.
|
|
554
|
+
|
|
555
|
+
This suppresses only the release DELETE's own errors. An exception
|
|
556
|
+
raised by the refresh body still propagates: when the body raised, this
|
|
557
|
+
coroutine runs in the ``finally``, returns normally after swallowing any
|
|
558
|
+
release error, and the original body exception continues to unwind.
|
|
559
|
+
"""
|
|
560
|
+
last_exc: Exception | None = None
|
|
561
|
+
for attempt in range(_CLAIM_RELEASE_MAX_ATTEMPTS):
|
|
562
|
+
try:
|
|
563
|
+
await claims.release(account_id, owner=owner)
|
|
564
|
+
return
|
|
565
|
+
except Exception as exc: # noqa: BLE001 - release must never mask the refresh result
|
|
566
|
+
last_exc = exc
|
|
567
|
+
if attempt < _CLAIM_RELEASE_MAX_ATTEMPTS - 1:
|
|
568
|
+
await asyncio.sleep(_CLAIM_RELEASE_RETRY_BASE_SECONDS * (2**attempt))
|
|
569
|
+
logger.warning(
|
|
570
|
+
"Failed to release refresh claim for account_id=%s after %d attempts; leaving it to "
|
|
571
|
+
"expire by TTL (the committed refresh result is unaffected)",
|
|
572
|
+
self._diagnostic_value(account_id),
|
|
573
|
+
_CLAIM_RELEASE_MAX_ATTEMPTS,
|
|
574
|
+
exc_info=None if self._redact_sensitive_details else last_exc,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
async def _perform_refresh(
|
|
578
|
+
self,
|
|
579
|
+
account: Account,
|
|
580
|
+
*,
|
|
581
|
+
refresh_token_encrypted: bytes,
|
|
582
|
+
deadline: float | None = None,
|
|
583
|
+
) -> Account:
|
|
584
|
+
attempted_fingerprint = _refresh_token_material_fingerprint(self._encryptor, refresh_token_encrypted)
|
|
585
|
+
refresh_token = self._encryptor.decrypt(refresh_token_encrypted)
|
|
586
|
+
try:
|
|
587
|
+
result = await self._refresh_tokens(refresh_token, account=account)
|
|
588
|
+
except RefreshError as exc:
|
|
589
|
+
if exc.is_permanent:
|
|
590
|
+
adopted = await self._handle_permanent_refresh_failure(
|
|
591
|
+
account, exc, attempted_fingerprint, deadline=deadline
|
|
592
|
+
)
|
|
593
|
+
if adopted is not None:
|
|
594
|
+
return adopted
|
|
595
|
+
raise
|
|
596
|
+
|
|
597
|
+
new_access_token_encrypted = self._encryptor.encrypt(result.access_token)
|
|
598
|
+
new_refresh_token_encrypted = self._encryptor.encrypt(result.refresh_token)
|
|
599
|
+
new_id_token_encrypted = self._encryptor.encrypt(result.id_token)
|
|
600
|
+
new_last_refresh = utcnow()
|
|
601
|
+
new_chatgpt_account_id = result.account_id or account.chatgpt_account_id
|
|
602
|
+
new_chatgpt_user_id = result.chatgpt_user_id or account.chatgpt_user_id
|
|
603
|
+
if result.plan_type is not None:
|
|
604
|
+
new_plan_type = coerce_account_plan_type(
|
|
605
|
+
result.plan_type,
|
|
606
|
+
account.plan_type or DEFAULT_PLAN,
|
|
607
|
+
)
|
|
608
|
+
elif not account.plan_type:
|
|
609
|
+
new_plan_type = DEFAULT_PLAN
|
|
610
|
+
else:
|
|
611
|
+
new_plan_type = account.plan_type
|
|
612
|
+
new_email = result.email or account.email
|
|
613
|
+
incoming_workspace_id = _clean_optional(result.workspace_id)
|
|
614
|
+
current_workspace_id = _clean_optional(account.workspace_id)
|
|
615
|
+
next_workspace_id = current_workspace_id
|
|
616
|
+
if incoming_workspace_id and current_workspace_id and current_workspace_id != incoming_workspace_id:
|
|
617
|
+
logger.warning(
|
|
618
|
+
"Refresh payload reported workspace_id=%s for account_id=%s while existing "
|
|
619
|
+
"workspace_id=%s is already set; keeping slot identity",
|
|
620
|
+
self._diagnostic_value(incoming_workspace_id),
|
|
621
|
+
self._diagnostic_value(account.id),
|
|
622
|
+
self._diagnostic_value(current_workspace_id),
|
|
623
|
+
)
|
|
624
|
+
next_workspace_id = current_workspace_id
|
|
625
|
+
elif not current_workspace_id and incoming_workspace_id:
|
|
626
|
+
slot_taken = await self._repo.workspace_slot_taken(
|
|
627
|
+
account_id=account.id,
|
|
628
|
+
email=new_email,
|
|
629
|
+
chatgpt_account_id=new_chatgpt_account_id,
|
|
630
|
+
workspace_id=incoming_workspace_id,
|
|
631
|
+
)
|
|
632
|
+
if slot_taken:
|
|
633
|
+
logger.warning(
|
|
634
|
+
"Refresh payload reported workspace_id=%s for legacy account_id=%s, but that slot "
|
|
635
|
+
"is already owned by another account; keeping unknown workspace",
|
|
636
|
+
self._diagnostic_value(incoming_workspace_id),
|
|
637
|
+
self._diagnostic_value(account.id),
|
|
638
|
+
)
|
|
639
|
+
else:
|
|
640
|
+
next_workspace_id = incoming_workspace_id
|
|
641
|
+
workspace_matches_current_slot = incoming_workspace_id is None or incoming_workspace_id == next_workspace_id
|
|
642
|
+
new_workspace_label = account.workspace_label
|
|
643
|
+
new_seat_type = account.seat_type
|
|
644
|
+
if workspace_matches_current_slot and result.workspace_label:
|
|
645
|
+
new_workspace_label = result.workspace_label
|
|
646
|
+
if workspace_matches_current_slot and result.seat_type:
|
|
647
|
+
new_seat_type = result.seat_type
|
|
648
|
+
|
|
649
|
+
async def _write_tokens(expected_refresh_token_encrypted: bytes) -> bool:
|
|
650
|
+
return await self._repo.rotate_tokens(
|
|
651
|
+
account.id,
|
|
652
|
+
access_token_encrypted=new_access_token_encrypted,
|
|
653
|
+
refresh_token_encrypted=new_refresh_token_encrypted,
|
|
654
|
+
id_token_encrypted=new_id_token_encrypted,
|
|
655
|
+
last_refresh=new_last_refresh,
|
|
656
|
+
plan_type=new_plan_type,
|
|
657
|
+
email=new_email,
|
|
658
|
+
chatgpt_account_id=new_chatgpt_account_id,
|
|
659
|
+
chatgpt_user_id=new_chatgpt_user_id or None,
|
|
660
|
+
workspace_id=next_workspace_id,
|
|
661
|
+
workspace_label=new_workspace_label,
|
|
662
|
+
seat_type=new_seat_type,
|
|
663
|
+
expected_refresh_token_encrypted=expected_refresh_token_encrypted,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
adopted = await self._persist_refreshed_tokens(
|
|
667
|
+
account,
|
|
668
|
+
write=_write_tokens,
|
|
669
|
+
expected_refresh_token_encrypted=refresh_token_encrypted,
|
|
670
|
+
deadline=deadline,
|
|
671
|
+
)
|
|
672
|
+
if adopted is not None:
|
|
673
|
+
return adopted
|
|
674
|
+
|
|
675
|
+
account.access_token_encrypted = new_access_token_encrypted
|
|
676
|
+
account.refresh_token_encrypted = new_refresh_token_encrypted
|
|
677
|
+
account.id_token_encrypted = new_id_token_encrypted
|
|
678
|
+
account.last_refresh = new_last_refresh
|
|
679
|
+
account.chatgpt_account_id = new_chatgpt_account_id
|
|
680
|
+
account.chatgpt_user_id = new_chatgpt_user_id
|
|
681
|
+
account.plan_type = new_plan_type
|
|
682
|
+
account.email = new_email
|
|
683
|
+
account.workspace_id = next_workspace_id
|
|
684
|
+
account.workspace_label = new_workspace_label
|
|
685
|
+
account.seat_type = new_seat_type
|
|
686
|
+
return account
|
|
687
|
+
|
|
688
|
+
async def _persist_refreshed_tokens(
|
|
689
|
+
self,
|
|
690
|
+
account: Account,
|
|
691
|
+
*,
|
|
692
|
+
write: Callable[[bytes], Awaitable[bool]],
|
|
693
|
+
expected_refresh_token_encrypted: bytes,
|
|
694
|
+
deadline: float | None = None,
|
|
695
|
+
) -> Account | None:
|
|
696
|
+
"""Persist freshly rotated tokens through a *guarded* compare-and-set only.
|
|
697
|
+
|
|
698
|
+
Returns the latest account row to adopt when a peer committed a
|
|
699
|
+
genuinely newer refresh-token rotation (different material) — that write
|
|
700
|
+
must never be clobbered. Returns ``None`` when our own rotation was
|
|
701
|
+
persisted (the caller then mirrors the new tokens onto its object), or
|
|
702
|
+
when the row vanished.
|
|
703
|
+
|
|
704
|
+
INVARIANT: there is NO unconditional token write anywhere in this
|
|
705
|
+
helper. Every persist is a compare-and-set conditioned on the exact
|
|
706
|
+
refresh-token ciphertext observed in the immediately-preceding read
|
|
707
|
+
(``WHERE refresh_token_encrypted == :expected``). That comparison is
|
|
708
|
+
atomic in the database, so there is no read->write gap: if ANYTHING
|
|
709
|
+
changed the row after our read — a non-deterministic re-encryption of the
|
|
710
|
+
same plaintext OR a genuine peer rotation — the guarded write MISSES and
|
|
711
|
+
clobbers nothing.
|
|
712
|
+
|
|
713
|
+
This structurally resolves the long-oscillating tension between "don't
|
|
714
|
+
drop the freshly-rotated token" and "don't clobber a genuine peer
|
|
715
|
+
rotation". The old exhaustion tail did an unconditional ``write(None)``
|
|
716
|
+
after a final plaintext-confirming re-read, which left a TOCTOU gap: a
|
|
717
|
+
peer rotation landing between that read and the unconditional write was
|
|
718
|
+
clobbered with a token minted from the already-consumed material.
|
|
719
|
+
Removing the unconditional write closes BOTH horns at once:
|
|
720
|
+
|
|
721
|
+
* (A) don't drop the freshly rotated token — when the stored plaintext is
|
|
722
|
+
confirmed to be the same token we consumed (only re-encrypted), we do
|
|
723
|
+
NOT give up: we retry the ciphertext-guarded CAS against the freshly
|
|
724
|
+
observed ciphertext so our new token still lands.
|
|
725
|
+
* (B) don't clobber a peer rotation — because every write is guarded, a
|
|
726
|
+
genuine rotation that lands in the former read->write gap now simply
|
|
727
|
+
causes a MISS, and we re-read, see the different plaintext, and ADOPT
|
|
728
|
+
it instead of overwriting.
|
|
729
|
+
|
|
730
|
+
On a guarded-write miss we re-read and decrypt the stored plaintext to
|
|
731
|
+
decide (never on the non-deterministic ciphertext):
|
|
732
|
+
|
|
733
|
+
* genuinely DIFFERENT valid plaintext -> a peer rotated -> ADOPT it
|
|
734
|
+
(return the peer row), never overwrite;
|
|
735
|
+
* SAME plaintext as the token we exchanged FROM (re-encryption noise of
|
|
736
|
+
the still-consumed token) -> retry the ciphertext-guarded CAS against
|
|
737
|
+
the newly observed ciphertext so our rotation wins;
|
|
738
|
+
* plaintext cannot be decrypted/compared -> raise the transient
|
|
739
|
+
``token_persist_conflict`` (we cannot prove a retry is safe).
|
|
740
|
+
|
|
741
|
+
When the bounded guarded retries are exhausted OR the claim/caller
|
|
742
|
+
deadline cuts the retry loop mid-storm, we do NOT drop the freshly rotated
|
|
743
|
+
token: we run a DEDICATED small bounded retry loop
|
|
744
|
+
(``_FINAL_PERSIST_MAX_ATTEMPTS`` attempts with tiny backoff) that is
|
|
745
|
+
DELIBERATELY SEPARATE from the claim/caller deadline. Persisting a valid
|
|
746
|
+
single-use rotated token is worth a few extra milliseconds over budget,
|
|
747
|
+
because giving up here strands the account holding the already-consumed
|
|
748
|
+
token. Each final attempt is still a ciphertext-guarded compare-and-set
|
|
749
|
+
keyed on the freshly re-read ciphertext, so it is safe — it lands only if
|
|
750
|
+
nothing changed since that read (persisting our new token and evicting the
|
|
751
|
+
already-consumed one) and clobbers nothing otherwise. On each miss we
|
|
752
|
+
re-read and decide on the DECRYPTED plaintext: a genuinely different peer
|
|
753
|
+
plaintext is ADOPTED (never overwritten, done); the same plaintext merely
|
|
754
|
+
re-encrypted is RETRIED against the newly observed ciphertext. Because any
|
|
755
|
+
ciphertext change means a writer committed, and no realistic writer
|
|
756
|
+
re-encrypts the same consumed token in a tight loop, this lands within a
|
|
757
|
+
couple of attempts in practice.
|
|
758
|
+
|
|
759
|
+
SAFE TERMINAL OUTCOME. Only if the dedicated final retries are ALL
|
|
760
|
+
exhausted while the stored material stays the already-consumed token (a
|
|
761
|
+
truly pathological same-plaintext storm, or undecryptable stored material
|
|
762
|
+
— neither occurs in real production) do we give up — but we FAIL CLOSED
|
|
763
|
+
rather than surfacing ordinary transient contention. A bare transient
|
|
764
|
+
``token_persist_conflict`` here would release the claim and let a later
|
|
765
|
+
blind retry re-exchange the still-stored consumed token, turning this
|
|
766
|
+
persistence race into an ``invalid_grant``/reauth PERMANENT knockout of a
|
|
767
|
+
healthy account. Instead we flag the account ``REAUTH_REQUIRED`` through
|
|
768
|
+
the SAME ciphertext-guarded status path (see
|
|
769
|
+
``_flag_persist_conflict_reauth``): the dead stored token is explicitly
|
|
770
|
+
surfaced to operators, a peer rotation that lands in the guard window is
|
|
771
|
+
ADOPTED (never clobbered), and the account is never left silently holding
|
|
772
|
+
a consumed token that a blind retry would permanently knock out.
|
|
773
|
+
|
|
774
|
+
This is what ends the long "drop the rotated token vs clobber a peer
|
|
775
|
+
rotation" oscillation, and closes the irreducible trilemma corner
|
|
776
|
+
(never-clobber vs never-drop vs bounded-time) that only appears in a
|
|
777
|
+
same-plaintext storm where a writer re-encrypts the SAME consumed token
|
|
778
|
+
faster than our CAS lands, repeatedly: the dedicated retries make the
|
|
779
|
+
persist land in all realistic cases, and the safe terminal flag makes the
|
|
780
|
+
pathological corner recoverable instead of a knockout. Every write remains
|
|
781
|
+
a guarded compare-and-set, so nothing is ever clobbered.
|
|
782
|
+
"""
|
|
783
|
+
consumed_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, expected_refresh_token_encrypted)
|
|
784
|
+
expected = expected_refresh_token_encrypted
|
|
785
|
+
deadline_elapsed = False
|
|
786
|
+
for _attempt in range(_TOKEN_CAS_MAX_ATTEMPTS):
|
|
787
|
+
if await write(expected):
|
|
788
|
+
# Guarded write landed: the row still held exactly the ciphertext
|
|
789
|
+
# we observed, so our freshly rotated token replaced it atomically
|
|
790
|
+
# and nothing was clobbered.
|
|
791
|
+
return None
|
|
792
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
793
|
+
if latest is None:
|
|
794
|
+
# Row is gone; nothing to persist or adopt.
|
|
795
|
+
return None
|
|
796
|
+
latest_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, latest.refresh_token_encrypted)
|
|
797
|
+
if consumed_plaintext is None or latest_plaintext is None:
|
|
798
|
+
# Cannot prove plaintext identity, so we cannot prove another
|
|
799
|
+
# guarded retry (or an adoption) is safe. A guarded persist was
|
|
800
|
+
# already ATTEMPTED just above (it missed), so surfacing a
|
|
801
|
+
# transient (non-permanent) error here is still a last resort, not
|
|
802
|
+
# a raise-in-place-of-persist. The caller retries once the
|
|
803
|
+
# contention clears; ``transport_error`` keeps it out of the
|
|
804
|
+
# permanent-failure cooldown cache.
|
|
805
|
+
logger.warning(
|
|
806
|
+
"Token-refresh compare-and-set for account_id=%s missed and the stored "
|
|
807
|
+
"refresh-token plaintext could not be decrypted for comparison; surfacing a "
|
|
808
|
+
"transient error rather than risking a clobber",
|
|
809
|
+
self._diagnostic_value(account.id),
|
|
810
|
+
)
|
|
811
|
+
raise RefreshError(
|
|
812
|
+
"token_persist_conflict",
|
|
813
|
+
(
|
|
814
|
+
f"Token-refresh compare-and-set for account_id={account.id} could not persist "
|
|
815
|
+
f"rotated tokens (stored refresh-token plaintext was undecryptable)"
|
|
816
|
+
),
|
|
817
|
+
False,
|
|
818
|
+
transport_error=True,
|
|
819
|
+
)
|
|
820
|
+
if latest_plaintext != consumed_plaintext:
|
|
821
|
+
# A peer stored genuinely newer refresh-token material after our
|
|
822
|
+
# read; the guarded write MISSED and clobbered nothing. Adopt it
|
|
823
|
+
# rather than overwriting with the token we already consumed.
|
|
824
|
+
return _adopt_account_row(account, latest)
|
|
825
|
+
# Same refresh-token plaintext, re-encrypted concurrently: retry the
|
|
826
|
+
# guarded CAS against the freshly observed ciphertext so our rotation
|
|
827
|
+
# lands only while the consumed material is still stored. No
|
|
828
|
+
# unconditional write is ever issued.
|
|
829
|
+
expected = latest.refresh_token_encrypted
|
|
830
|
+
# The RETRY LOOP -- not the final safety persist below -- is bounded by
|
|
831
|
+
# the claim/caller deadline: a contended DB write must not keep the
|
|
832
|
+
# loop (and the held claim) spinning past the budget, after which a
|
|
833
|
+
# peer could win the claim and re-exchange the already-consumed token.
|
|
834
|
+
# On deadline expiry we STOP retrying but still fall through to the
|
|
835
|
+
# single final guarded persist so the rotated token is never dropped
|
|
836
|
+
# unpersisted.
|
|
837
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
838
|
+
deadline_elapsed = True
|
|
839
|
+
break
|
|
840
|
+
|
|
841
|
+
# FINAL, always-guarded persist of the freshly rotated token, keyed on the
|
|
842
|
+
# LAST-OBSERVED ciphertext. Reached when the bounded retries were exhausted
|
|
843
|
+
# OR the deadline cut the retry loop. This runs a DEDICATED small bounded
|
|
844
|
+
# retry loop that is DELIBERATELY SEPARATE from the claim/caller deadline:
|
|
845
|
+
# persisting a valid single-use rotated token is worth a few extra
|
|
846
|
+
# milliseconds over budget, because giving up here strands the account
|
|
847
|
+
# holding the already-consumed token. Each attempt is a single
|
|
848
|
+
# compare-and-set, so it lands only if nothing changed since the last read
|
|
849
|
+
# (persisting our new token and evicting the consumed one) and clobbers
|
|
850
|
+
# nothing otherwise; on each miss we re-read and decide on the decrypted
|
|
851
|
+
# plaintext (ADOPT a genuine peer rotation, retry a same-plaintext
|
|
852
|
+
# re-encryption against the newly observed ciphertext). Because any
|
|
853
|
+
# ciphertext change means a writer committed and no realistic writer
|
|
854
|
+
# re-encrypts the same consumed token in a tight loop, this lands within a
|
|
855
|
+
# couple of attempts in practice.
|
|
856
|
+
for final_attempt in range(_FINAL_PERSIST_MAX_ATTEMPTS):
|
|
857
|
+
if await write(expected):
|
|
858
|
+
return None
|
|
859
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
860
|
+
if latest is None:
|
|
861
|
+
# Row is gone; nothing to persist or adopt.
|
|
862
|
+
return None
|
|
863
|
+
latest_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, latest.refresh_token_encrypted)
|
|
864
|
+
if _is_genuine_peer_rotation(consumed_plaintext, latest_plaintext):
|
|
865
|
+
# A genuine peer rotation landed on the final re-read: ADOPT it (our
|
|
866
|
+
# freshly rotated token is legitimately superseded), never overwrite.
|
|
867
|
+
return _adopt_account_row(account, latest)
|
|
868
|
+
if consumed_plaintext is None or latest_plaintext is None:
|
|
869
|
+
# Cannot prove plaintext identity, so we cannot prove another
|
|
870
|
+
# guarded retry is safe. Stop the dedicated retries and fall
|
|
871
|
+
# through to the SAFE terminal outcome rather than spinning.
|
|
872
|
+
break
|
|
873
|
+
# Same refresh-token plaintext, re-encrypted concurrently: retry the
|
|
874
|
+
# guarded CAS against the freshly observed ciphertext so our rotation
|
|
875
|
+
# lands only while the consumed material is still stored.
|
|
876
|
+
expected = latest.refresh_token_encrypted
|
|
877
|
+
if final_attempt < _FINAL_PERSIST_MAX_ATTEMPTS - 1:
|
|
878
|
+
await asyncio.sleep(_FINAL_PERSIST_RETRY_BASE_SECONDS * (2**final_attempt))
|
|
879
|
+
|
|
880
|
+
# SAFE TERMINAL OUTCOME. The dedicated final retries were ALL exhausted
|
|
881
|
+
# while the stored material stayed the already-consumed token (a truly
|
|
882
|
+
# pathological same-plaintext storm, or undecryptable stored material).
|
|
883
|
+
# We must NOT surface an ordinary transient ``token_persist_conflict``
|
|
884
|
+
# here: that would release the claim and let a later blind retry re-read
|
|
885
|
+
# and re-exchange the still-stored consumed single-use token, turning this
|
|
886
|
+
# persistence race into an ``invalid_grant``/reauth PERMANENT knockout of
|
|
887
|
+
# an otherwise-healthy account. Fail CLOSED instead by flagging the account
|
|
888
|
+
# REAUTH_REQUIRED through the SAME ciphertext-guarded status path, so the
|
|
889
|
+
# dead stored token is explicitly surfaced to operators (never silently
|
|
890
|
+
# retried) and a peer rotation that lands in the guard window is still
|
|
891
|
+
# ADOPTED rather than clobbered.
|
|
892
|
+
return await self._flag_persist_conflict_reauth(
|
|
893
|
+
account,
|
|
894
|
+
expected_refresh_token_encrypted=expected,
|
|
895
|
+
consumed_plaintext=consumed_plaintext,
|
|
896
|
+
deadline_elapsed=deadline_elapsed,
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
async def _flag_persist_conflict_reauth(
|
|
900
|
+
self,
|
|
901
|
+
account: Account,
|
|
902
|
+
*,
|
|
903
|
+
expected_refresh_token_encrypted: bytes,
|
|
904
|
+
consumed_plaintext: str | None,
|
|
905
|
+
deadline_elapsed: bool,
|
|
906
|
+
) -> Account | None:
|
|
907
|
+
"""SAFE terminal outcome for a truly pathological final-persist storm.
|
|
908
|
+
|
|
909
|
+
Reached only when the DEDICATED bounded final-persist retries were ALL
|
|
910
|
+
exhausted while the database kept holding the already-consumed refresh
|
|
911
|
+
token (a sustained same-plaintext re-encryption storm, or undecryptable
|
|
912
|
+
stored material — neither occurs in real production, since token
|
|
913
|
+
re-encryption is a one-time re-auth/import event, not a tight loop).
|
|
914
|
+
|
|
915
|
+
Rather than surfacing a bare transient ``token_persist_conflict`` — which
|
|
916
|
+
releases the claim and lets a later blind retry re-exchange the stored
|
|
917
|
+
consumed token into an ``invalid_grant``/reauth PERMANENT knockout of a
|
|
918
|
+
healthy account — this fails CLOSED by flagging the account
|
|
919
|
+
``REAUTH_REQUIRED`` through the SAME ciphertext-guarded status CAS keyed on
|
|
920
|
+
the last-observed (consumed-token) ciphertext:
|
|
921
|
+
|
|
922
|
+
* guarded CAS LANDS -> the account is EXPLICITLY flagged for re-auth (its
|
|
923
|
+
durable state truly holds a dead token), so operators repair it instead
|
|
924
|
+
of a blind retry silently knocking it out. Return the flagged row.
|
|
925
|
+
* guarded CAS MISSES on a genuinely DIFFERENT peer plaintext -> a peer
|
|
926
|
+
re-authenticated/rotated; the account is repaired, so ADOPT the peer row
|
|
927
|
+
(never clobbered), do not flag reauth.
|
|
928
|
+
* guarded CAS keeps missing on same-plaintext re-encryption through its
|
|
929
|
+
own bounded budget -> only THEN surface the transient
|
|
930
|
+
``token_persist_conflict`` as the last resort (no worse than before, and
|
|
931
|
+
astronomically unlikely). ``transport_error`` keeps it out of the
|
|
932
|
+
permanent-failure cooldown cache.
|
|
933
|
+
|
|
934
|
+
Every write is a guarded compare-and-set, so a genuine peer rotation is
|
|
935
|
+
never overwritten in any branch.
|
|
936
|
+
"""
|
|
937
|
+
status = AccountStatus.REAUTH_REQUIRED
|
|
938
|
+
reason = "Refresh token persistence conflict; stored token is stale - re-login required"
|
|
939
|
+
expected = expected_refresh_token_encrypted
|
|
940
|
+
for _attempt in range(_FINAL_PERSIST_MAX_ATTEMPTS):
|
|
941
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
942
|
+
if latest is None:
|
|
943
|
+
# Row is gone; nothing to flag or adopt.
|
|
944
|
+
return None
|
|
945
|
+
latest_plaintext = _decrypt_refresh_token_plaintext(self._encryptor, latest.refresh_token_encrypted)
|
|
946
|
+
if _is_genuine_peer_rotation(consumed_plaintext, latest_plaintext):
|
|
947
|
+
# A peer rotated genuinely newer material: the account is repaired.
|
|
948
|
+
# ADOPT it; do NOT flag reauth on a healthy rotated row.
|
|
949
|
+
return _adopt_account_row(account, latest)
|
|
950
|
+
expected = latest.refresh_token_encrypted
|
|
951
|
+
applied = await self._repo.update_status_if_current(
|
|
952
|
+
account.id,
|
|
953
|
+
status,
|
|
954
|
+
reason,
|
|
955
|
+
expected_status=latest.status,
|
|
956
|
+
expected_deactivation_reason=latest.deactivation_reason,
|
|
957
|
+
expected_reset_at=latest.reset_at,
|
|
958
|
+
expected_refresh_token_encrypted=expected,
|
|
959
|
+
)
|
|
960
|
+
if applied:
|
|
961
|
+
account.status = status
|
|
962
|
+
account.deactivation_reason = reason
|
|
963
|
+
get_account_selection_cache().invalidate()
|
|
964
|
+
logger.warning(
|
|
965
|
+
"Token-refresh compare-and-set for account_id=%s could not persist the freshly "
|
|
966
|
+
"rotated token after the dedicated final-persist retries (%s); flagged the account "
|
|
967
|
+
"REAUTH_REQUIRED via the guarded status path so it is explicitly repaired rather "
|
|
968
|
+
"than left holding a consumed token that a blind retry would permanently knock out",
|
|
969
|
+
self._diagnostic_value(account.id),
|
|
970
|
+
"claim/caller deadline elapsed" if deadline_elapsed else "same-plaintext storm exhausted",
|
|
971
|
+
)
|
|
972
|
+
return account
|
|
973
|
+
# Guarded status CAS missed on unchanged material; re-read and retry.
|
|
974
|
+
# Even the guarded reauth flag could not land on unchanged material through
|
|
975
|
+
# its own bounded budget: surface the transient (non-permanent) conflict as
|
|
976
|
+
# the last resort. ``transport_error`` keeps it out of the permanent-failure
|
|
977
|
+
# cooldown cache and never risks a clobber.
|
|
978
|
+
logger.warning(
|
|
979
|
+
"Token-refresh compare-and-set for account_id=%s could not persist the freshly rotated "
|
|
980
|
+
"token nor flag REAUTH_REQUIRED after the dedicated final-persist retries; surfacing a "
|
|
981
|
+
"transient conflict so the caller retries the whole refresh rather than risking a clobber",
|
|
982
|
+
self._diagnostic_value(account.id),
|
|
983
|
+
)
|
|
984
|
+
raise RefreshError(
|
|
985
|
+
"token_persist_conflict",
|
|
986
|
+
(
|
|
987
|
+
f"Token-refresh compare-and-set for account_id={account.id} could not persist "
|
|
988
|
+
f"rotated tokens nor flag REAUTH_REQUIRED after a final guarded attempt missed on "
|
|
989
|
+
f"unchanged material"
|
|
990
|
+
),
|
|
991
|
+
False,
|
|
992
|
+
transport_error=True,
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
async def _handle_permanent_refresh_failure(
|
|
996
|
+
self,
|
|
997
|
+
account: Account,
|
|
998
|
+
exc: RefreshError,
|
|
999
|
+
attempted_fingerprint: str,
|
|
1000
|
+
*,
|
|
1001
|
+
deadline: float | None = None,
|
|
1002
|
+
) -> Account | None:
|
|
1003
|
+
"""Persist a permanent refresh failure without clobbering a concurrent rotation.
|
|
1004
|
+
|
|
1005
|
+
Returns the latest account row when its refresh-token material rotated
|
|
1006
|
+
after this attempt began (the caller adopts it instead of raising);
|
|
1007
|
+
returns ``None`` when the permanent failure stands (the status CAS
|
|
1008
|
+
landed, or the row vanished). Raises a TRANSIENT ``RefreshError``
|
|
1009
|
+
(``transport_error=True``, non-permanent) when the status-downgrade CAS
|
|
1010
|
+
is EXHAUSTED by contention while the account still holds the failed
|
|
1011
|
+
material — we could not authoritatively persist ``REAUTH_REQUIRED``, so
|
|
1012
|
+
the caller must retry rather than fall back to the unguarded
|
|
1013
|
+
``LoadBalancer.mark_permanent_failure()`` write that could clobber a
|
|
1014
|
+
peer rotation landing in the same window. The comparison uses the
|
|
1015
|
+
fingerprint of the material this attempt exchanged, captured before the
|
|
1016
|
+
fresh re-read, because ``get_by_id_fresh`` may refresh the caller's own
|
|
1017
|
+
identity-map object in place.
|
|
1018
|
+
|
|
1019
|
+
The status downgrade uses a compare-and-set conditioned on the freshly
|
|
1020
|
+
observed account state including the refresh-token ciphertext: a
|
|
1021
|
+
concurrent re-auth/import can change that ciphertext between the fresh
|
|
1022
|
+
re-read and the write. As with token persistence, a ciphertext change is
|
|
1023
|
+
not by itself a newer rotation — Fernet is non-deterministic, so a
|
|
1024
|
+
re-auth/import that re-encrypts the SAME plaintext changes the bytes
|
|
1025
|
+
without issuing a new token. When the fingerprint is still unchanged the
|
|
1026
|
+
account is holding the very material that just failed permanently, so we
|
|
1027
|
+
re-read and retry the CAS (bounded) against the freshly observed
|
|
1028
|
+
ciphertext rather than skipping the downgrade and leaving the account
|
|
1029
|
+
active with dead credentials. Only a genuinely different fingerprint is
|
|
1030
|
+
adopted as a peer rotation.
|
|
1031
|
+
"""
|
|
1032
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
1033
|
+
if latest is None:
|
|
1034
|
+
# Account row is gone; nothing to downgrade.
|
|
1035
|
+
return None
|
|
1036
|
+
if (
|
|
1037
|
+
_refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
|
|
1038
|
+
!= attempted_fingerprint
|
|
1039
|
+
):
|
|
1040
|
+
return _adopt_account_row(account, latest)
|
|
1041
|
+
reason = PERMANENT_FAILURE_CODES.get(exc.code, exc.message)
|
|
1042
|
+
status = account_status_for_permanent_failure(exc.code)
|
|
1043
|
+
for attempt in range(_TOKEN_CAS_MAX_ATTEMPTS):
|
|
1044
|
+
# The FIRST status CAS always runs so a genuine permanent failure is
|
|
1045
|
+
# persisted best-effort. The RETRIES are additionally bounded by the
|
|
1046
|
+
# claim/caller deadline (not just the attempt count): the status
|
|
1047
|
+
# write loop runs while the claim is held, and a contended DB write
|
|
1048
|
+
# must not keep looping -- and holding the claim -- past the budget.
|
|
1049
|
+
# On deadline expiry surface the transient status-downgrade conflict
|
|
1050
|
+
# so the claim is released and the caller retries rather than running
|
|
1051
|
+
# the unguarded permanent mark that could clobber a peer rotation.
|
|
1052
|
+
if attempt > 0 and deadline is not None and time.monotonic() >= deadline:
|
|
1053
|
+
logger.warning(
|
|
1054
|
+
"Permanent refresh-failure status CAS for account_id=%s code=%s exceeded the "
|
|
1055
|
+
"claim/caller deadline after %d attempt(s); surfacing a transient conflict and "
|
|
1056
|
+
"releasing the claim rather than looping past the budget",
|
|
1057
|
+
self._diagnostic_value(account.id),
|
|
1058
|
+
exc.code,
|
|
1059
|
+
attempt,
|
|
1060
|
+
)
|
|
1061
|
+
raise RefreshError(
|
|
1062
|
+
"status_downgrade_conflict",
|
|
1063
|
+
(
|
|
1064
|
+
f"Permanent refresh-failure status downgrade for account_id={account.id} "
|
|
1065
|
+
f"(code={exc.code}) exceeded the refresh claim/caller deadline before it "
|
|
1066
|
+
f"could persist REAUTH_REQUIRED"
|
|
1067
|
+
),
|
|
1068
|
+
False,
|
|
1069
|
+
transport_error=True,
|
|
1070
|
+
) from exc
|
|
1071
|
+
applied = await self._repo.update_status_if_current(
|
|
1072
|
+
account.id,
|
|
1073
|
+
status,
|
|
1074
|
+
reason,
|
|
1075
|
+
expected_status=latest.status,
|
|
1076
|
+
expected_deactivation_reason=latest.deactivation_reason,
|
|
1077
|
+
expected_reset_at=latest.reset_at,
|
|
1078
|
+
expected_refresh_token_encrypted=latest.refresh_token_encrypted,
|
|
1079
|
+
)
|
|
1080
|
+
if applied:
|
|
1081
|
+
account.status = status
|
|
1082
|
+
account.deactivation_reason = reason
|
|
1083
|
+
if status == AccountStatus.DEACTIVATED:
|
|
1084
|
+
mark_account_routing_unavailable(account.id)
|
|
1085
|
+
get_account_selection_cache().invalidate()
|
|
1086
|
+
return None
|
|
1087
|
+
# CAS missed: the freshly observed account state changed between the
|
|
1088
|
+
# re-read and the write. Re-read to decide why.
|
|
1089
|
+
latest = await self._repo.get_by_id_fresh(account.id)
|
|
1090
|
+
if latest is None:
|
|
1091
|
+
return None
|
|
1092
|
+
if (
|
|
1093
|
+
_refresh_token_material_fingerprint(self._encryptor, latest.refresh_token_encrypted)
|
|
1094
|
+
!= attempted_fingerprint
|
|
1095
|
+
):
|
|
1096
|
+
# A concurrent re-auth/import committed a genuinely different
|
|
1097
|
+
# refresh token in the CAS window. The account is repaired;
|
|
1098
|
+
# adopt the freshly rotated row (mirroring the pre-CAS check
|
|
1099
|
+
# above) instead of re-raising. Returning ``None`` here would
|
|
1100
|
+
# make ``_perform_refresh`` re-raise the original permanent
|
|
1101
|
+
# ``RefreshError``, and proxy callers then commonly invoke
|
|
1102
|
+
# ``LoadBalancer.mark_permanent_failure()`` whose ``update_status``
|
|
1103
|
+
# path is NOT guarded by this refresh-token CAS — so it would
|
|
1104
|
+
# clobber the peer's valid rotation with ``REAUTH_REQUIRED`` and
|
|
1105
|
+
# tear down sessions for an account that was just repaired.
|
|
1106
|
+
return _adopt_account_row(account, latest)
|
|
1107
|
+
# Same refresh-token plaintext, merely re-encrypted (non-deterministic
|
|
1108
|
+
# Fernet) — or an unrelated status/reason/reset nudge. The account is
|
|
1109
|
+
# still holding the material that just failed permanently, so retry
|
|
1110
|
+
# the CAS against the freshly observed ciphertext so the downgrade
|
|
1111
|
+
# lands rather than leaving a dead account active.
|
|
1112
|
+
# The bounded status-downgrade compare-and-set never landed: the account
|
|
1113
|
+
# still holds the material that failed permanently, but a sustained
|
|
1114
|
+
# re-encryption storm kept changing the observed ciphertext so we could
|
|
1115
|
+
# not authoritatively persist REAUTH_REQUIRED. Returning ``None`` here
|
|
1116
|
+
# would make ``_perform_refresh`` re-raise the ORIGINAL permanent
|
|
1117
|
+
# ``RefreshError``; proxy callers then commonly invoke
|
|
1118
|
+
# ``LoadBalancer.mark_permanent_failure()`` whose ``update_status`` path
|
|
1119
|
+
# is NOT guarded by the refresh-token ciphertext CAS — so a genuine peer
|
|
1120
|
+
# re-auth/import rotation that lands after our final re-read but before
|
|
1121
|
+
# that fallback write would be clobbered with ``REAUTH_REQUIRED``,
|
|
1122
|
+
# exactly the repaired-account clobber the CAS guards exist to prevent.
|
|
1123
|
+
# Since we could not win an atomic CAS window, surface a transient
|
|
1124
|
+
# (non-permanent) error so the caller RETRIES the whole refresh once the
|
|
1125
|
+
# contention clears rather than running the unguarded permanent mark.
|
|
1126
|
+
# ``transport_error`` keeps it out of the permanent-failure cooldown
|
|
1127
|
+
# cache. A genuinely different peer rotation is still adopted above (that
|
|
1128
|
+
# stays a repair); a status CAS that SUCCEEDS above still stands as a
|
|
1129
|
+
# real permanent failure — only contention-driven exhaustion becomes
|
|
1130
|
+
# transient here.
|
|
1131
|
+
logger.warning(
|
|
1132
|
+
"Permanent refresh-failure status CAS for account_id=%s code=%s kept missing on "
|
|
1133
|
+
"unchanged token material after %d attempts; surfacing a transient error so the caller "
|
|
1134
|
+
"retries rather than running the unguarded permanent-failure mark that could clobber a "
|
|
1135
|
+
"concurrent peer rotation",
|
|
1136
|
+
self._diagnostic_value(account.id),
|
|
1137
|
+
exc.code,
|
|
1138
|
+
_TOKEN_CAS_MAX_ATTEMPTS,
|
|
1139
|
+
)
|
|
1140
|
+
raise RefreshError(
|
|
1141
|
+
"status_downgrade_conflict",
|
|
1142
|
+
(
|
|
1143
|
+
f"Permanent refresh-failure status downgrade for account_id={account.id} "
|
|
1144
|
+
f"(code={exc.code}) could not persist REAUTH_REQUIRED after "
|
|
1145
|
+
f"{_TOKEN_CAS_MAX_ATTEMPTS} attempts due to concurrent writes"
|
|
1146
|
+
),
|
|
1147
|
+
False,
|
|
1148
|
+
transport_error=True,
|
|
1149
|
+
) from exc
|
|
1150
|
+
|
|
1151
|
+
async def _refresh_tokens(self, refresh_token: str, *, account: Account) -> TokenRefreshResult:
|
|
1152
|
+
# Bound the ENTIRE claim-holding work of this body — the token-refresh
|
|
1153
|
+
# admission wait AND the upstream OAuth exchange — by the caller's
|
|
1154
|
+
# remaining refresh budget. This runs inside the shielded singleflight
|
|
1155
|
+
# task that outlives a cancelled caller and holds the cross-replica DB
|
|
1156
|
+
# refresh claim, so any unbounded wait here keeps the claim held past the
|
|
1157
|
+
# request deadline and blocks peer replicas on the same account's claim.
|
|
1158
|
+
#
|
|
1159
|
+
# ``token_refresh_timeout_override`` (set by the claim path to the
|
|
1160
|
+
# remaining budget) only caps the HTTP exchange; without help,
|
|
1161
|
+
# ``WorkAdmissionController`` waits up to
|
|
1162
|
+
# ``ADMISSION_WAIT_TIMEOUT_SECONDS`` for a slot on a saturated
|
|
1163
|
+
# token-refresh semaphore BEFORE that HTTP timeout is even armed. Derive
|
|
1164
|
+
# one monotonic deadline from the budget and enforce it on BOTH the
|
|
1165
|
+
# admission acquire and the exchange so their sum cannot overrun it.
|
|
1166
|
+
budget = get_token_refresh_timeout_override()
|
|
1167
|
+
deadline = time.monotonic() + max(0.0, budget) if budget is not None else None
|
|
1168
|
+
|
|
1169
|
+
refresh_lease = await self._acquire_refresh_admission_bounded(account, deadline)
|
|
1170
|
+
try:
|
|
1171
|
+
async with get_background_session() as session:
|
|
1172
|
+
try:
|
|
1173
|
+
route = await resolve_upstream_route(
|
|
1174
|
+
session,
|
|
1175
|
+
account_id=account.id,
|
|
1176
|
+
operation="token_refresh",
|
|
1177
|
+
scope="account",
|
|
1178
|
+
encryptor=self._encryptor,
|
|
1179
|
+
)
|
|
1180
|
+
except UpstreamProxyRouteError as exc:
|
|
1181
|
+
raise RefreshError(
|
|
1182
|
+
"upstream_proxy_unavailable",
|
|
1183
|
+
f"Upstream proxy route unavailable: {exc.reason}",
|
|
1184
|
+
False,
|
|
1185
|
+
transport_error=True,
|
|
1186
|
+
upstream_proxy_fail_closed_reason=exc.reason,
|
|
1187
|
+
) from exc
|
|
1188
|
+
if route is None and await _account_has_active_proxy_binding(session, account.id):
|
|
1189
|
+
raise RefreshError(
|
|
1190
|
+
"upstream_proxy_unavailable",
|
|
1191
|
+
"Account has an active proxy binding but no route resolved",
|
|
1192
|
+
False,
|
|
1193
|
+
transport_error=True,
|
|
1194
|
+
upstream_proxy_fail_closed_reason="binding_route_unavailable",
|
|
1195
|
+
)
|
|
1196
|
+
# Cap the exchange to what is actually LEFT after the admission wait
|
|
1197
|
+
# (and route resolution). The pushed override still carries the full
|
|
1198
|
+
# pre-admission remaining budget, so re-derive it here; otherwise a
|
|
1199
|
+
# long admission wait followed by a full-remaining-budget exchange
|
|
1200
|
+
# could hold the claim for up to twice the budget.
|
|
1201
|
+
exchange_override: contextvars.Token[float | None] | None = None
|
|
1202
|
+
if deadline is not None:
|
|
1203
|
+
remaining = deadline - time.monotonic()
|
|
1204
|
+
if remaining <= 0:
|
|
1205
|
+
raise RefreshError(
|
|
1206
|
+
"refresh_claim_timeout",
|
|
1207
|
+
f"Token refresh for account {account.id} exhausted its refresh budget after "
|
|
1208
|
+
f"acquiring token-refresh admission but before the upstream exchange could start",
|
|
1209
|
+
False,
|
|
1210
|
+
transport_error=True,
|
|
1211
|
+
)
|
|
1212
|
+
exchange_override = push_token_refresh_timeout_override(remaining)
|
|
1213
|
+
try:
|
|
1214
|
+
return await _call_with_supported_optional_kwargs(
|
|
1215
|
+
refresh_access_token,
|
|
1216
|
+
refresh_token,
|
|
1217
|
+
optional_kwargs={
|
|
1218
|
+
"route": route,
|
|
1219
|
+
"allow_direct_egress": route is None,
|
|
1220
|
+
},
|
|
1221
|
+
)
|
|
1222
|
+
finally:
|
|
1223
|
+
if exchange_override is not None:
|
|
1224
|
+
pop_token_refresh_timeout_override(exchange_override)
|
|
1225
|
+
finally:
|
|
1226
|
+
if refresh_lease is not None:
|
|
1227
|
+
refresh_lease.release()
|
|
1228
|
+
|
|
1229
|
+
async def _acquire_refresh_admission_bounded(
|
|
1230
|
+
self,
|
|
1231
|
+
account: Account,
|
|
1232
|
+
deadline: float | None,
|
|
1233
|
+
) -> RefreshAdmissionLeasePort | None:
|
|
1234
|
+
"""Acquire token-refresh admission without overrunning the caller budget.
|
|
1235
|
+
|
|
1236
|
+
``WorkAdmissionController`` waits up to
|
|
1237
|
+
``ADMISSION_WAIT_TIMEOUT_SECONDS`` for a slot on a saturated
|
|
1238
|
+
token-refresh semaphore. That wait happens while this shielded task
|
|
1239
|
+
already holds the cross-replica DB refresh claim, so it MUST be capped by
|
|
1240
|
+
the caller's remaining refresh budget (``deadline``): otherwise a
|
|
1241
|
+
small-budget request would hold the claim — and block peer replicas on
|
|
1242
|
+
the same account — for the full admission timeout. When the budget is
|
|
1243
|
+
already exhausted before/at admission, fail fast with the transient
|
|
1244
|
+
``refresh_claim_timeout`` so the claim is released (by the caller's
|
|
1245
|
+
``finally``) rather than held for the full wait.
|
|
1246
|
+
"""
|
|
1247
|
+
if self._acquire_refresh_admission is None:
|
|
1248
|
+
return None
|
|
1249
|
+
if deadline is None:
|
|
1250
|
+
return await self._acquire_refresh_admission()
|
|
1251
|
+
admission_wait = deadline - time.monotonic()
|
|
1252
|
+
if admission_wait <= 0:
|
|
1253
|
+
raise RefreshError(
|
|
1254
|
+
"refresh_claim_timeout",
|
|
1255
|
+
f"Token refresh for account {account.id} exhausted its refresh budget before "
|
|
1256
|
+
f"token-refresh admission could be acquired",
|
|
1257
|
+
False,
|
|
1258
|
+
transport_error=True,
|
|
1259
|
+
)
|
|
1260
|
+
try:
|
|
1261
|
+
return await asyncio.wait_for(self._acquire_refresh_admission(), timeout=admission_wait)
|
|
1262
|
+
except asyncio.TimeoutError as exc:
|
|
1263
|
+
raise RefreshError(
|
|
1264
|
+
"refresh_claim_timeout",
|
|
1265
|
+
f"Token refresh for account {account.id} exhausted its refresh budget waiting for "
|
|
1266
|
+
f"token-refresh admission on a saturated concurrency gate",
|
|
1267
|
+
False,
|
|
1268
|
+
transport_error=True,
|
|
1269
|
+
) from exc
|
|
1270
|
+
|
|
1271
|
+
async def _ensure_chatgpt_account_id(self, account: Account) -> Account:
|
|
1272
|
+
if account.chatgpt_account_id:
|
|
1273
|
+
return account
|
|
1274
|
+
try:
|
|
1275
|
+
id_token = self._encryptor.decrypt(account.id_token_encrypted)
|
|
1276
|
+
except Exception:
|
|
1277
|
+
return account
|
|
1278
|
+
raw_account_id = _chatgpt_account_id_from_id_token(id_token)
|
|
1279
|
+
if not raw_account_id:
|
|
1280
|
+
return account
|
|
1281
|
+
|
|
1282
|
+
account.chatgpt_account_id = raw_account_id
|
|
1283
|
+
try:
|
|
1284
|
+
# This backfill runs on EVERY ensure_fresh for a legacy account (the
|
|
1285
|
+
# fast, no-refresh path included) against the caller's selection-time
|
|
1286
|
+
# snapshot, OUTSIDE any refresh claim. It derives and persists only
|
|
1287
|
+
# the missing chatgpt_account_id, so it routes through the
|
|
1288
|
+
# metadata-only writer, which STRUCTURALLY cannot write token
|
|
1289
|
+
# ciphertext. That makes it impossible for this stale in-memory
|
|
1290
|
+
# snapshot to rewrite the refresh-token material and clobber a peer
|
|
1291
|
+
# replica's concurrent single-use rotation -- the exact lost-update
|
|
1292
|
+
# the refresh persist path was rebuilt to forbid, which previously
|
|
1293
|
+
# re-entered via this sibling.
|
|
1294
|
+
await self._repo.update_account_metadata(
|
|
1295
|
+
account.id,
|
|
1296
|
+
chatgpt_account_id=raw_account_id,
|
|
1297
|
+
)
|
|
1298
|
+
except Exception:
|
|
1299
|
+
logger.warning(
|
|
1300
|
+
"Failed to persist chatgpt_account_id account_id=%s",
|
|
1301
|
+
self._diagnostic_value(account.id),
|
|
1302
|
+
exc_info=None if self._redact_sensitive_details else True,
|
|
1303
|
+
)
|
|
1304
|
+
return account
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _chatgpt_account_id_from_id_token(id_token: str) -> str | None:
|
|
1308
|
+
claims = extract_id_token_claims(id_token)
|
|
1309
|
+
auth_claims = claims.auth or OpenAIAuthClaims()
|
|
1310
|
+
return auth_claims.chatgpt_account_id or claims.chatgpt_account_id
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _refresh_singleflight_key(
|
|
1314
|
+
encryptor: TokenEncryptor,
|
|
1315
|
+
account: Account,
|
|
1316
|
+
) -> _RefreshSingleflightKey:
|
|
1317
|
+
return (
|
|
1318
|
+
account.id,
|
|
1319
|
+
_refresh_token_material_fingerprint(encryptor, account.refresh_token_encrypted),
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _adopt_account_row(target: Account, source: Account) -> Account:
|
|
1324
|
+
"""Copy a concurrently committed row's state onto the caller's account object.
|
|
1325
|
+
|
|
1326
|
+
``source`` is attached to the refresh task's short-lived session; returning
|
|
1327
|
+
it directly would hand callers an object that expires when that session
|
|
1328
|
+
closes. Copying onto the caller's object mirrors how a successful refresh
|
|
1329
|
+
reports its result.
|
|
1330
|
+
"""
|
|
1331
|
+
if target is source:
|
|
1332
|
+
return target
|
|
1333
|
+
for column in Account.__table__.columns:
|
|
1334
|
+
if column.name in ("id", "created_at"):
|
|
1335
|
+
continue
|
|
1336
|
+
setattr(target, column.name, getattr(source, column.name))
|
|
1337
|
+
return target
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _terminal_status_refresh_error(account: Account) -> RefreshError:
|
|
1341
|
+
"""Build the PERMANENT ``RefreshError`` that surfaces a prior holder's terminal status.
|
|
1342
|
+
|
|
1343
|
+
Reached only when a claim waiter wins the released claim and re-reads a row
|
|
1344
|
+
whose refresh material is UNCHANGED but whose status is already terminal
|
|
1345
|
+
(a prior holder committed REAUTH_REQUIRED/DEACTIVATED without rotating the
|
|
1346
|
+
token). The chosen code maps back to the SAME committed status through
|
|
1347
|
+
:func:`account_status_for_permanent_failure`, so a downstream
|
|
1348
|
+
``LoadBalancer.mark_permanent_failure`` re-affirms the terminal state rather
|
|
1349
|
+
than flipping REAUTH_REQUIRED to DEACTIVATED (or vice versa).
|
|
1350
|
+
"""
|
|
1351
|
+
code = "account_deactivated" if account.status == AccountStatus.DEACTIVATED else "refresh_token_invalidated"
|
|
1352
|
+
reason = account.deactivation_reason or PERMANENT_FAILURE_CODES.get(code, "re-login required")
|
|
1353
|
+
return RefreshError(
|
|
1354
|
+
code,
|
|
1355
|
+
(
|
|
1356
|
+
f"Token refresh for account {account.id} not attempted: a prior refresh-claim holder "
|
|
1357
|
+
f"already flagged the account {account.status.value} without rotating the refresh token "
|
|
1358
|
+
f"({reason}); reusing the unchanged consumed token would repeat a permanent failure"
|
|
1359
|
+
),
|
|
1360
|
+
True,
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _refresh_token_material_fingerprint(encryptor: TokenEncryptor, refresh_token_encrypted: bytes) -> str:
|
|
1365
|
+
try:
|
|
1366
|
+
material = encryptor.decrypt(refresh_token_encrypted).encode("utf-8")
|
|
1367
|
+
except Exception:
|
|
1368
|
+
material = refresh_token_encrypted
|
|
1369
|
+
return sha256(material).hexdigest()
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _decrypt_refresh_token_plaintext(encryptor: TokenEncryptor, refresh_token_encrypted: bytes) -> str | None:
|
|
1373
|
+
"""Decrypt refresh-token ciphertext to its plaintext, or ``None`` if undecryptable.
|
|
1374
|
+
|
|
1375
|
+
Unlike :func:`_refresh_token_material_fingerprint` (which silently falls back
|
|
1376
|
+
to hashing the raw ciphertext when decryption fails), this returns ``None`` so
|
|
1377
|
+
callers can distinguish a genuine plaintext comparison from an impossible one.
|
|
1378
|
+
"""
|
|
1379
|
+
try:
|
|
1380
|
+
return encryptor.decrypt(refresh_token_encrypted)
|
|
1381
|
+
except Exception:
|
|
1382
|
+
return None
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _is_genuine_peer_rotation(consumed_plaintext: str | None, latest_plaintext: str | None) -> bool:
|
|
1386
|
+
"""True when the freshly re-read plaintext is a genuinely DIFFERENT refresh
|
|
1387
|
+
token than the one this attempt exchanged FROM (a peer rotation to ADOPT),
|
|
1388
|
+
rather than the same consumed token merely re-encrypted or an undecryptable
|
|
1389
|
+
blob. Both plaintexts must decrypt for the comparison to be trustworthy."""
|
|
1390
|
+
return consumed_plaintext is not None and latest_plaintext is not None and latest_plaintext != consumed_plaintext
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _clean_optional(value: str | None) -> str | None:
|
|
1394
|
+
if not isinstance(value, str):
|
|
1395
|
+
return None
|
|
1396
|
+
cleaned = value.strip()
|
|
1397
|
+
return cleaned or None
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
async def _call_with_supported_optional_kwargs(
|
|
1401
|
+
func: Callable[..., Awaitable[Any]],
|
|
1402
|
+
/,
|
|
1403
|
+
*args: Any,
|
|
1404
|
+
optional_kwargs: Mapping[str, Any],
|
|
1405
|
+
**required_kwargs: Any,
|
|
1406
|
+
) -> Any:
|
|
1407
|
+
kwargs = dict(required_kwargs)
|
|
1408
|
+
kwargs.update(optional_kwargs)
|
|
1409
|
+
try:
|
|
1410
|
+
signature = inspect.signature(func)
|
|
1411
|
+
except (TypeError, ValueError):
|
|
1412
|
+
signature = None
|
|
1413
|
+
accepts_var_keyword = signature is not None and any(
|
|
1414
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()
|
|
1415
|
+
)
|
|
1416
|
+
if signature is not None and not accepts_var_keyword:
|
|
1417
|
+
for name in optional_kwargs:
|
|
1418
|
+
if name not in signature.parameters:
|
|
1419
|
+
kwargs.pop(name, None)
|
|
1420
|
+
return await func(*args, **kwargs)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _clear_refresh_singleflight_state() -> None:
|
|
1424
|
+
_REFRESH_SINGLEFLIGHT.clear()
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
async def _account_has_active_proxy_binding(session: AsyncSession, account_id: str) -> bool:
|
|
1428
|
+
try:
|
|
1429
|
+
result = await session.execute(
|
|
1430
|
+
select(AccountProxyBinding.id)
|
|
1431
|
+
.where(
|
|
1432
|
+
AccountProxyBinding.account_id == account_id,
|
|
1433
|
+
AccountProxyBinding.is_active.is_(True),
|
|
1434
|
+
)
|
|
1435
|
+
.limit(1)
|
|
1436
|
+
)
|
|
1437
|
+
return result.scalar_one_or_none() is not None
|
|
1438
|
+
except OperationalError:
|
|
1439
|
+
return False
|