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,1679 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import case, delete, func, or_, select, text, update
|
|
11
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
12
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
13
|
+
from sqlalchemy.exc import OperationalError
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
from app.core.auth import extract_id_token_claims, resolve_seat_identity
|
|
17
|
+
from app.core.crypto import TokenEncryptor
|
|
18
|
+
from app.core.upstream_proxy.cache import get_upstream_route_cache
|
|
19
|
+
from app.core.utils.time import utcnow
|
|
20
|
+
from app.db.account_identity_lock import advisory_lock_key, lock_postgresql_account_identities
|
|
21
|
+
from app.db.models import (
|
|
22
|
+
Account,
|
|
23
|
+
AccountLimitWarmup,
|
|
24
|
+
AccountStatus,
|
|
25
|
+
AccountUsageRollup,
|
|
26
|
+
AdditionalUsageHistory,
|
|
27
|
+
ApiKeyAccountAssignment,
|
|
28
|
+
DashboardSettings,
|
|
29
|
+
HttpBridgeSessionAlias,
|
|
30
|
+
HttpBridgeSessionRecord,
|
|
31
|
+
HttpBridgeSessionState,
|
|
32
|
+
RequestLog,
|
|
33
|
+
RuntimeSentinel,
|
|
34
|
+
StickySession,
|
|
35
|
+
StickySessionKind,
|
|
36
|
+
UsageHistory,
|
|
37
|
+
)
|
|
38
|
+
from app.db.session import sqlite_writer_section
|
|
39
|
+
from app.db.sqlite_lock_retry import retry_on_sqlite_lock
|
|
40
|
+
from app.modules.accounts.usage_rollup import (
|
|
41
|
+
AccountUsageRollupRepository,
|
|
42
|
+
deduped_usage_aggregate_stmt,
|
|
43
|
+
lock_fold_state,
|
|
44
|
+
merge_rollups_into,
|
|
45
|
+
)
|
|
46
|
+
from app.modules.accounts.usage_time_rollup import (
|
|
47
|
+
merge_time_rollups_into,
|
|
48
|
+
mirror_account_hard_delete_into_time_rollups,
|
|
49
|
+
mirror_account_soft_delete_into_time_rollups,
|
|
50
|
+
)
|
|
51
|
+
from app.modules.usage.additional_quota_keys import normalize_additional_quota_routing_policy_overrides
|
|
52
|
+
from app.modules.usage.plan_downgrade_observations import discard_plan_downgrade_observations
|
|
53
|
+
from app.modules.usage.repository import _clear_bulk_history_since_sqlite_cache
|
|
54
|
+
|
|
55
|
+
_SETTINGS_ROW_ID = 1
|
|
56
|
+
_DUPLICATE_ACCOUNT_SUFFIX = "__copy"
|
|
57
|
+
# deactivation_reason stamped by the fast DELETE path while the background
|
|
58
|
+
# worker drains the account's rows. The authoritative pending marker is
|
|
59
|
+
# accounts.delete_requested_at; the reason string is operator-facing only.
|
|
60
|
+
ACCOUNT_PENDING_DELETION_REASON = "pending_deletion"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def credentials_replaced_since_wipe(
|
|
64
|
+
access_token_encrypted: bytes,
|
|
65
|
+
refresh_token_encrypted: bytes,
|
|
66
|
+
id_token_encrypted: bytes,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""True when a marked account's token ciphertext is no longer the
|
|
69
|
+
empty-credential wipe stamped by :meth:`AccountsRepository.begin_delete`.
|
|
70
|
+
|
|
71
|
+
New-code credential replacements clear the pending-deletion marker in the
|
|
72
|
+
same transaction, but a replacement handled by a PRE-UPGRADE replica
|
|
73
|
+
during a rolling deploy writes fresh ciphertext without knowing the
|
|
74
|
+
marker columns. Fresh (non-wiped) credentials on a still-marked row are
|
|
75
|
+
therefore themselves the supersede signal; the caller must clear the
|
|
76
|
+
marker and abandon the deletion. ALL THREE token fields are inspected: a
|
|
77
|
+
legal replacement may carry an empty refresh token while providing fresh
|
|
78
|
+
access/id material, and mistaking it for the wipe would finalize a
|
|
79
|
+
freshly replaced account. Undecryptable material also counts as
|
|
80
|
+
replaced — never finalize a row whose credentials we cannot attribute to
|
|
81
|
+
our own wipe.
|
|
82
|
+
"""
|
|
83
|
+
encryptor = TokenEncryptor()
|
|
84
|
+
for ciphertext in (access_token_encrypted, refresh_token_encrypted, id_token_encrypted):
|
|
85
|
+
try:
|
|
86
|
+
if encryptor.decrypt(ciphertext) != "":
|
|
87
|
+
return True
|
|
88
|
+
except Exception:
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_UNSET = object()
|
|
94
|
+
_HARD_STICKY_UNAVAILABLE_STATUSES = frozenset(
|
|
95
|
+
(AccountStatus.PAUSED, AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED)
|
|
96
|
+
)
|
|
97
|
+
_HARD_STICKY_OUTAGE_GRACE_SEEDED_SENTINEL = "hard_sticky_outage_grace_seeded"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_missing_hard_sticky_seed_table(exc: OperationalError) -> bool:
|
|
101
|
+
"""Return True when startup grace seeding hit a not-yet-migrated database.
|
|
102
|
+
|
|
103
|
+
``seed_hard_sticky_outage_grace_on_startup`` runs unguarded in the app
|
|
104
|
+
lifespan. With ``database_migrate_on_startup=false`` (operator-managed
|
|
105
|
+
migrations) the process can boot against a legacy database that does not
|
|
106
|
+
have ``runtime_sentinels`` (or, on a brand-new file, ``accounts``) yet;
|
|
107
|
+
before this guard that boot crashed with an unhandled sqlite
|
|
108
|
+
``OperationalError``. The seeding is a best-effort one-time backfill: on a
|
|
109
|
+
missing table it rolls back and returns 0 without stamping the sentinel,
|
|
110
|
+
so the first boot after migrations run still performs the backfill. Only
|
|
111
|
+
the sqlite "no such table" shape is matched — this deployment mode boots
|
|
112
|
+
sqlite before migration; every other OperationalError still propagates.
|
|
113
|
+
"""
|
|
114
|
+
message = str(exc).lower()
|
|
115
|
+
return "no such table: runtime_sentinels" in message or "no such table: accounts" in message
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class AccountRequestUsageSummary:
|
|
120
|
+
request_count: int
|
|
121
|
+
total_tokens: int
|
|
122
|
+
cached_input_tokens: int
|
|
123
|
+
total_cost_usd: float
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# The account-listing request-usage summary dedupes and re-aggregates the
|
|
127
|
+
# un-folded raw tail on every dashboard accounts load, and the displayed
|
|
128
|
+
# lifetime totals tolerate short staleness. Cache the merged summaries per
|
|
129
|
+
# account-id signature for a small fixed TTL, mirroring the request-log
|
|
130
|
+
# COUNT cache (issue #1340 / PRINCIPLES.md P2); the test suite patches the
|
|
131
|
+
# TTL to 0 so summaries stay exact within a test. Account deletion and
|
|
132
|
+
# duplicate-identity consolidation clear the cache because they re-attribute
|
|
133
|
+
# usage rather than merely append to it.
|
|
134
|
+
_SUMMARY_CACHE_TTL_SECONDS = 30.0
|
|
135
|
+
_SUMMARY_CACHE_MAX_ENTRIES = 64
|
|
136
|
+
_request_usage_summary_cache: dict[tuple[str, ...] | None, tuple[dict[str, AccountRequestUsageSummary], float]] = {}
|
|
137
|
+
# Invalidation generation: a fill that was already computing when a clear
|
|
138
|
+
# happened must not re-populate the cache with its pre-clear result. Fills
|
|
139
|
+
# capture the generation before their first await and stores are discarded
|
|
140
|
+
# on mismatch. Deletion/consolidation clear synchronously right after their
|
|
141
|
+
# commit (no await in between), so every store either precedes the commit
|
|
142
|
+
# (its stale data is wiped by the clear) or observes the bumped generation.
|
|
143
|
+
_summary_cache_generation = 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _clear_request_usage_summary_cache() -> None:
|
|
147
|
+
global _summary_cache_generation
|
|
148
|
+
_summary_cache_generation += 1
|
|
149
|
+
_request_usage_summary_cache.clear()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cached_request_usage_summaries(
|
|
153
|
+
key: tuple[str, ...] | None,
|
|
154
|
+
) -> dict[str, AccountRequestUsageSummary] | None:
|
|
155
|
+
entry = _request_usage_summary_cache.get(key)
|
|
156
|
+
if entry is None:
|
|
157
|
+
return None
|
|
158
|
+
summaries, expires_at = entry
|
|
159
|
+
if time.monotonic() >= expires_at:
|
|
160
|
+
_request_usage_summary_cache.pop(key, None)
|
|
161
|
+
return None
|
|
162
|
+
return summaries
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _store_request_usage_summaries(
|
|
166
|
+
key: tuple[str, ...] | None,
|
|
167
|
+
summaries: dict[str, AccountRequestUsageSummary],
|
|
168
|
+
ttl_seconds: float,
|
|
169
|
+
generation: int,
|
|
170
|
+
) -> None:
|
|
171
|
+
if generation != _summary_cache_generation:
|
|
172
|
+
return
|
|
173
|
+
if len(_request_usage_summary_cache) >= _SUMMARY_CACHE_MAX_ENTRIES:
|
|
174
|
+
oldest = min(
|
|
175
|
+
_request_usage_summary_cache,
|
|
176
|
+
key=lambda existing: _request_usage_summary_cache[existing][1],
|
|
177
|
+
)
|
|
178
|
+
_request_usage_summary_cache.pop(oldest, None)
|
|
179
|
+
_request_usage_summary_cache[key] = (summaries, time.monotonic() + ttl_seconds)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class AccountIdentityConflictError(Exception):
|
|
183
|
+
def __init__(self, email: str) -> None:
|
|
184
|
+
self.email = email
|
|
185
|
+
super().__init__(
|
|
186
|
+
f"Cannot overwrite account for email '{email}' because multiple matching accounts exist. "
|
|
187
|
+
"Remove duplicates or enable import without overwrite."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AccountIdentityRelockError(RuntimeError):
|
|
192
|
+
"""Raised after identity membership changes across both bounded lock attempts."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AccountsRepository:
|
|
196
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
197
|
+
self._session = session
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def session(self) -> AsyncSession:
|
|
201
|
+
return self._session
|
|
202
|
+
|
|
203
|
+
async def get_by_id(self, account_id: str) -> Account | None:
|
|
204
|
+
return await self._session.get(Account, account_id)
|
|
205
|
+
|
|
206
|
+
async def get_by_id_fresh(self, account_id: str) -> Account | None:
|
|
207
|
+
"""Re-read one account with a real SELECT, refreshing the identity map.
|
|
208
|
+
|
|
209
|
+
Unlike ``get_by_id`` (``session.get``), this never returns a cached
|
|
210
|
+
identity-map object without hitting the database, so callers checking
|
|
211
|
+
for concurrently rotated token material observe the latest committed
|
|
212
|
+
row.
|
|
213
|
+
"""
|
|
214
|
+
result = await self._session.execute(
|
|
215
|
+
select(Account).where(Account.id == account_id).execution_options(populate_existing=True)
|
|
216
|
+
)
|
|
217
|
+
return result.scalar_one_or_none()
|
|
218
|
+
|
|
219
|
+
async def list_accounts(self, *, refresh_existing: bool = False) -> list[Account]:
|
|
220
|
+
# Accounts marked for background deletion are already deleted from the
|
|
221
|
+
# operator's point of view: they never appear in listings (dashboard,
|
|
222
|
+
# usage refresh, automations) even though their rows survive until the
|
|
223
|
+
# deletion worker finishes draining them.
|
|
224
|
+
stmt = select(Account).where(Account.delete_requested_at.is_(None)).order_by(Account.email)
|
|
225
|
+
if refresh_existing:
|
|
226
|
+
stmt = stmt.execution_options(populate_existing=True)
|
|
227
|
+
result = await self._session.execute(stmt)
|
|
228
|
+
return list(result.scalars().all())
|
|
229
|
+
|
|
230
|
+
async def list_accounts_by_ids(self, account_ids: list[str], *, refresh_existing: bool = False) -> list[Account]:
|
|
231
|
+
if not account_ids:
|
|
232
|
+
return []
|
|
233
|
+
stmt = (
|
|
234
|
+
select(Account)
|
|
235
|
+
.where(Account.id.in_(account_ids))
|
|
236
|
+
.where(Account.delete_requested_at.is_(None))
|
|
237
|
+
.order_by(Account.email)
|
|
238
|
+
)
|
|
239
|
+
if refresh_existing:
|
|
240
|
+
stmt = stmt.execution_options(populate_existing=True)
|
|
241
|
+
result = await self._session.execute(stmt)
|
|
242
|
+
return list(result.scalars().all())
|
|
243
|
+
|
|
244
|
+
async def list_request_usage_summary_by_account(
|
|
245
|
+
self,
|
|
246
|
+
account_ids: list[str] | None = None,
|
|
247
|
+
) -> dict[str, AccountRequestUsageSummary]:
|
|
248
|
+
ttl_seconds = _SUMMARY_CACHE_TTL_SECONDS
|
|
249
|
+
cache_key = tuple(sorted(account_ids)) if account_ids is not None else None
|
|
250
|
+
generation = _summary_cache_generation
|
|
251
|
+
if ttl_seconds > 0:
|
|
252
|
+
cached = _cached_request_usage_summaries(cache_key)
|
|
253
|
+
if cached is not None:
|
|
254
|
+
return dict(cached)
|
|
255
|
+
rollup_repo = AccountUsageRollupRepository(self._session)
|
|
256
|
+
folded, watermark = await rollup_repo.read_state(account_ids)
|
|
257
|
+
|
|
258
|
+
merged: dict[str, list[float]] = {
|
|
259
|
+
account_id: [
|
|
260
|
+
sums.request_count,
|
|
261
|
+
sums.input_tokens,
|
|
262
|
+
sums.output_tokens,
|
|
263
|
+
sums.cached_input_tokens,
|
|
264
|
+
sums.total_cost_usd,
|
|
265
|
+
]
|
|
266
|
+
for account_id, sums in folded.items()
|
|
267
|
+
}
|
|
268
|
+
tail_stmt = deduped_usage_aggregate_stmt(account_ids=account_ids, after_exclusive=watermark)
|
|
269
|
+
result = await self._session.execute(tail_stmt)
|
|
270
|
+
for (
|
|
271
|
+
account_id,
|
|
272
|
+
request_count,
|
|
273
|
+
input_tokens,
|
|
274
|
+
output_tokens,
|
|
275
|
+
cached_input_tokens,
|
|
276
|
+
total_cost_usd,
|
|
277
|
+
) in result.all():
|
|
278
|
+
if not account_id:
|
|
279
|
+
continue
|
|
280
|
+
totals = merged.setdefault(account_id, [0, 0, 0, 0, 0.0])
|
|
281
|
+
totals[0] += int(request_count or 0)
|
|
282
|
+
totals[1] += int(input_tokens or 0)
|
|
283
|
+
totals[2] += int(output_tokens or 0)
|
|
284
|
+
totals[3] += int(cached_input_tokens or 0)
|
|
285
|
+
totals[4] += float(total_cost_usd or 0.0)
|
|
286
|
+
|
|
287
|
+
summaries: dict[str, AccountRequestUsageSummary] = {}
|
|
288
|
+
for account_id, (request_count, input_sum, output_sum, cached_sum, total_cost_usd) in merged.items():
|
|
289
|
+
input_total = int(input_sum)
|
|
290
|
+
output_total = int(output_sum)
|
|
291
|
+
cached_total = max(0, min(int(cached_sum), input_total))
|
|
292
|
+
summaries[account_id] = AccountRequestUsageSummary(
|
|
293
|
+
request_count=int(request_count),
|
|
294
|
+
total_tokens=input_total + output_total,
|
|
295
|
+
cached_input_tokens=cached_total,
|
|
296
|
+
total_cost_usd=round(float(total_cost_usd), 6),
|
|
297
|
+
)
|
|
298
|
+
if ttl_seconds > 0:
|
|
299
|
+
_store_request_usage_summaries(cache_key, summaries, ttl_seconds, generation)
|
|
300
|
+
return dict(summaries)
|
|
301
|
+
return summaries
|
|
302
|
+
|
|
303
|
+
async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None:
|
|
304
|
+
result = await self._session.execute(
|
|
305
|
+
select(Account)
|
|
306
|
+
.where(Account.chatgpt_account_id == chatgpt_account_id)
|
|
307
|
+
.where(Account.status.notin_((AccountStatus.DEACTIVATED, AccountStatus.PAUSED)))
|
|
308
|
+
.limit(1)
|
|
309
|
+
)
|
|
310
|
+
return result.scalar_one_or_none()
|
|
311
|
+
|
|
312
|
+
async def upsert(
|
|
313
|
+
self,
|
|
314
|
+
account: Account,
|
|
315
|
+
*,
|
|
316
|
+
merge_by_email: bool | None = None,
|
|
317
|
+
merge_by_chatgpt_identity: bool = False,
|
|
318
|
+
) -> Account:
|
|
319
|
+
async with sqlite_writer_section():
|
|
320
|
+
return await self._upsert_unlocked(
|
|
321
|
+
account,
|
|
322
|
+
merge_by_email=merge_by_email,
|
|
323
|
+
merge_by_chatgpt_identity=merge_by_chatgpt_identity,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def _upsert_unlocked(
|
|
327
|
+
self,
|
|
328
|
+
account: Account,
|
|
329
|
+
*,
|
|
330
|
+
merge_by_email: bool | None = None,
|
|
331
|
+
merge_by_chatgpt_identity: bool = False,
|
|
332
|
+
_identity_lock_attempt: int = 0,
|
|
333
|
+
) -> Account:
|
|
334
|
+
dialect_name = self._dialect_name()
|
|
335
|
+
sqlite_lock_acquired = False
|
|
336
|
+
if merge_by_email is None:
|
|
337
|
+
if dialect_name == "sqlite":
|
|
338
|
+
await self._acquire_sqlite_merge_lock()
|
|
339
|
+
sqlite_lock_acquired = True
|
|
340
|
+
merge_by_email = await self._merge_by_email_enabled()
|
|
341
|
+
|
|
342
|
+
if dialect_name == "sqlite" and not sqlite_lock_acquired:
|
|
343
|
+
# sqlite BEGIN IMMEDIATE serializes all writers globally, so
|
|
344
|
+
# the identity-reconciliation branch is already mutually
|
|
345
|
+
# exclusive on this dialect.
|
|
346
|
+
await self._acquire_sqlite_merge_lock()
|
|
347
|
+
elif dialect_name == "postgresql":
|
|
348
|
+
# Upstream identity membership always serializes before email
|
|
349
|
+
# locks and account row locks. This applies to ordinary imports as
|
|
350
|
+
# well as explicit identity reconciliation because either can add,
|
|
351
|
+
# replace, or remove a membership used by live-usage fallback.
|
|
352
|
+
locked_identities = await self._lock_postgresql_upsert_identity_candidates(
|
|
353
|
+
account,
|
|
354
|
+
include_email=bool(merge_by_email),
|
|
355
|
+
)
|
|
356
|
+
if merge_by_email:
|
|
357
|
+
await self._acquire_postgresql_merge_lock(account.email)
|
|
358
|
+
elif not locked_identities:
|
|
359
|
+
await self._acquire_postgresql_identity_lock(account.id)
|
|
360
|
+
if not await self._postgresql_upsert_identity_candidates_are_locked(
|
|
361
|
+
account,
|
|
362
|
+
include_email=bool(merge_by_email),
|
|
363
|
+
locked_identities=locked_identities,
|
|
364
|
+
):
|
|
365
|
+
await self._session.rollback()
|
|
366
|
+
if _identity_lock_attempt >= 1:
|
|
367
|
+
raise AccountIdentityRelockError(
|
|
368
|
+
"Account identity candidates changed during PostgreSQL upsert locking"
|
|
369
|
+
)
|
|
370
|
+
return await self._upsert_unlocked(
|
|
371
|
+
account,
|
|
372
|
+
merge_by_email=merge_by_email,
|
|
373
|
+
merge_by_chatgpt_identity=merge_by_chatgpt_identity,
|
|
374
|
+
_identity_lock_attempt=_identity_lock_attempt + 1,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Identity-aware reconciliation runs before the deterministic-id
|
|
378
|
+
# check so that a deactivated row whose refresh token was revoked
|
|
379
|
+
# is reused on reauth instead of being shadowed by an __copy row
|
|
380
|
+
# under the same upstream ChatGPT identity (issue #788).
|
|
381
|
+
#
|
|
382
|
+
# This path is intentionally independent of merge_by_email: the
|
|
383
|
+
# OAuth reauth caller passes merge_by_chatgpt_identity=True even
|
|
384
|
+
# when the operator has opted into "import without overwrite",
|
|
385
|
+
# because that setting governs the dashboard import path (side-
|
|
386
|
+
# by-side rows for the same email) rather than the reauth path
|
|
387
|
+
# (one local row per upstream identity).
|
|
388
|
+
if merge_by_chatgpt_identity and account.chatgpt_account_id:
|
|
389
|
+
canonical = await self._account_by_chatgpt_identity(
|
|
390
|
+
account.chatgpt_account_id,
|
|
391
|
+
workspace_id=account.workspace_id,
|
|
392
|
+
email=account.email,
|
|
393
|
+
)
|
|
394
|
+
if canonical is not None:
|
|
395
|
+
await self._apply_account_replacement(canonical, account)
|
|
396
|
+
usage_cache_dirty = await self._reconcile_chatgpt_identity_duplicates(
|
|
397
|
+
canonical=canonical,
|
|
398
|
+
chatgpt_account_id=account.chatgpt_account_id,
|
|
399
|
+
workspace_id=account.workspace_id,
|
|
400
|
+
email=account.email,
|
|
401
|
+
)
|
|
402
|
+
await self._session.commit()
|
|
403
|
+
if usage_cache_dirty:
|
|
404
|
+
_clear_bulk_history_since_sqlite_cache()
|
|
405
|
+
# Consolidation re-attributes request logs and rollup sums
|
|
406
|
+
# to the canonical account; cached listing summaries would
|
|
407
|
+
# keep reporting the deleted duplicates until TTL expiry.
|
|
408
|
+
_clear_request_usage_summary_cache()
|
|
409
|
+
# Duplicate reconciliation deletes Account rows, cascading
|
|
410
|
+
# any account_proxy_bindings they owned. The route cache is
|
|
411
|
+
# keyed by deterministic account id, so stale duplicate-id
|
|
412
|
+
# outcomes must not survive the commit.
|
|
413
|
+
await get_upstream_route_cache().invalidate()
|
|
414
|
+
await self._session.refresh(canonical)
|
|
415
|
+
return canonical
|
|
416
|
+
|
|
417
|
+
existing = await self._session.get(Account, account.id)
|
|
418
|
+
if existing:
|
|
419
|
+
if merge_by_email or _is_workspace_less_reauth_for_known_slot(
|
|
420
|
+
existing,
|
|
421
|
+
account,
|
|
422
|
+
merge_by_chatgpt_identity=merge_by_chatgpt_identity,
|
|
423
|
+
):
|
|
424
|
+
await self._apply_account_replacement(existing, account)
|
|
425
|
+
await self._session.commit()
|
|
426
|
+
await self._session.refresh(existing)
|
|
427
|
+
return existing
|
|
428
|
+
account.id = await self._next_available_account_id(account.id)
|
|
429
|
+
|
|
430
|
+
if merge_by_email:
|
|
431
|
+
existing_by_email = await self._single_account_by_email(account.email)
|
|
432
|
+
if existing_by_email:
|
|
433
|
+
await self._apply_account_replacement(existing_by_email, account)
|
|
434
|
+
await self._session.commit()
|
|
435
|
+
await self._session.refresh(existing_by_email)
|
|
436
|
+
return existing_by_email
|
|
437
|
+
|
|
438
|
+
self._session.add(account)
|
|
439
|
+
await self._session.commit()
|
|
440
|
+
await self._session.refresh(account)
|
|
441
|
+
return account
|
|
442
|
+
|
|
443
|
+
async def upsert_reauthorized(self, account: Account) -> Account:
|
|
444
|
+
return await self.upsert_account_slot(account, preserve_unknown_workspace_duplicates=False)
|
|
445
|
+
|
|
446
|
+
async def replace_reauthorized(self, account_id: str, account: Account) -> Account | None:
|
|
447
|
+
"""Replace credentials on the exact local row selected for reauthentication."""
|
|
448
|
+
async with sqlite_writer_section():
|
|
449
|
+
if self._dialect_name() == "postgresql":
|
|
450
|
+
existing = await self._lock_postgresql_account_identity_membership(
|
|
451
|
+
account_id,
|
|
452
|
+
account.chatgpt_account_id,
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
existing = await self._session.get(Account, account_id)
|
|
456
|
+
if existing is None:
|
|
457
|
+
return None
|
|
458
|
+
await self._apply_account_replacement(existing, account)
|
|
459
|
+
await self._session.commit()
|
|
460
|
+
await self._session.refresh(existing)
|
|
461
|
+
return existing
|
|
462
|
+
|
|
463
|
+
async def _apply_account_replacement(self, target: Account, source: Account) -> None:
|
|
464
|
+
"""Apply freshly imported or reauthorized material onto an existing row.
|
|
465
|
+
|
|
466
|
+
Every in-place credential replacement goes through here rather than
|
|
467
|
+
calling :func:`_apply_account_updates` directly, so replacing a
|
|
468
|
+
credential always discards the account's pending plan-downgrade
|
|
469
|
+
evidence in the same transaction: evidence gathered under the previous
|
|
470
|
+
credential must not count toward a downgrade for the new one (#1456).
|
|
471
|
+
Routine token rotation is a different event with its own path
|
|
472
|
+
(:meth:`rotate_tokens`) and deliberately does not discard evidence.
|
|
473
|
+
"""
|
|
474
|
+
_apply_account_updates(target, source)
|
|
475
|
+
await discard_plan_downgrade_observations(self._session, target.id)
|
|
476
|
+
|
|
477
|
+
async def upsert_account_slot(
|
|
478
|
+
self,
|
|
479
|
+
account: Account,
|
|
480
|
+
*,
|
|
481
|
+
preserve_unknown_workspace_duplicates: bool | None = None,
|
|
482
|
+
preserve_identity_slots: bool = False,
|
|
483
|
+
) -> Account:
|
|
484
|
+
async with sqlite_writer_section():
|
|
485
|
+
return await self._upsert_account_slot_unlocked(
|
|
486
|
+
account,
|
|
487
|
+
preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
|
|
488
|
+
preserve_identity_slots=preserve_identity_slots,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
async def _upsert_account_slot_unlocked(
|
|
492
|
+
self,
|
|
493
|
+
account: Account,
|
|
494
|
+
*,
|
|
495
|
+
preserve_unknown_workspace_duplicates: bool | None = None,
|
|
496
|
+
preserve_identity_slots: bool = False,
|
|
497
|
+
_identity_lock_attempt: int = 0,
|
|
498
|
+
) -> Account:
|
|
499
|
+
if preserve_unknown_workspace_duplicates is None:
|
|
500
|
+
preserve_unknown_workspace_duplicates = not await self._merge_by_email_enabled()
|
|
501
|
+
dialect_name = self._dialect_name()
|
|
502
|
+
if dialect_name == "sqlite":
|
|
503
|
+
await self._acquire_sqlite_merge_lock()
|
|
504
|
+
elif dialect_name == "postgresql":
|
|
505
|
+
locked_identities = await self._lock_postgresql_upsert_identity_candidates(
|
|
506
|
+
account,
|
|
507
|
+
include_email=True,
|
|
508
|
+
)
|
|
509
|
+
for lock_key in sorted(
|
|
510
|
+
_slot_lock_keys(
|
|
511
|
+
account,
|
|
512
|
+
preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
|
|
513
|
+
)
|
|
514
|
+
):
|
|
515
|
+
await self._acquire_postgresql_identity_lock(lock_key)
|
|
516
|
+
if not await self._postgresql_upsert_identity_candidates_are_locked(
|
|
517
|
+
account,
|
|
518
|
+
include_email=True,
|
|
519
|
+
locked_identities=locked_identities,
|
|
520
|
+
):
|
|
521
|
+
await self._session.rollback()
|
|
522
|
+
if _identity_lock_attempt >= 1:
|
|
523
|
+
raise AccountIdentityRelockError(
|
|
524
|
+
"Account identity candidates changed during PostgreSQL slot locking"
|
|
525
|
+
)
|
|
526
|
+
return await self._upsert_account_slot_unlocked(
|
|
527
|
+
account,
|
|
528
|
+
preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
|
|
529
|
+
preserve_identity_slots=preserve_identity_slots,
|
|
530
|
+
_identity_lock_attempt=_identity_lock_attempt + 1,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
existing = await self._account_by_slot_identity(account)
|
|
534
|
+
if existing:
|
|
535
|
+
await self._apply_account_replacement(existing, account)
|
|
536
|
+
await self._session.commit()
|
|
537
|
+
await self._session.refresh(existing)
|
|
538
|
+
return existing
|
|
539
|
+
|
|
540
|
+
existing_by_id = await self._session.get(Account, account.id)
|
|
541
|
+
if existing_by_id:
|
|
542
|
+
if _same_unknown_workspace_identity(existing_by_id, account) and not preserve_unknown_workspace_duplicates:
|
|
543
|
+
await self._apply_account_replacement(existing_by_id, account)
|
|
544
|
+
await self._session.commit()
|
|
545
|
+
await self._session.refresh(existing_by_id)
|
|
546
|
+
return existing_by_id
|
|
547
|
+
account.id = await self._next_available_account_id(account.id)
|
|
548
|
+
elif not preserve_unknown_workspace_duplicates:
|
|
549
|
+
if _workspace_slot_key(account):
|
|
550
|
+
existing_by_email = await self._single_unknown_workspace_account_by_email(account.email)
|
|
551
|
+
elif preserve_identity_slots and account.chatgpt_account_id:
|
|
552
|
+
existing_by_email = None
|
|
553
|
+
else:
|
|
554
|
+
existing_by_email = await self._single_account_by_email(account.email)
|
|
555
|
+
if existing_by_email and not _can_reuse_email_fallback(existing_by_email, account):
|
|
556
|
+
existing_by_email = None
|
|
557
|
+
if existing_by_email:
|
|
558
|
+
await self._apply_account_replacement(existing_by_email, account)
|
|
559
|
+
await self._session.commit()
|
|
560
|
+
await self._session.refresh(existing_by_email)
|
|
561
|
+
return existing_by_email
|
|
562
|
+
|
|
563
|
+
self._session.add(account)
|
|
564
|
+
await self._session.commit()
|
|
565
|
+
await self._session.refresh(account)
|
|
566
|
+
return account
|
|
567
|
+
|
|
568
|
+
async def _account_by_chatgpt_identity(
|
|
569
|
+
self,
|
|
570
|
+
chatgpt_account_id: str,
|
|
571
|
+
*,
|
|
572
|
+
workspace_id: str | None,
|
|
573
|
+
email: str | None,
|
|
574
|
+
) -> Account | None:
|
|
575
|
+
"""Return the canonical local account row for a ChatGPT identity.
|
|
576
|
+
|
|
577
|
+
Order of preference, so that reauth targets the matching real-email
|
|
578
|
+
slot when one exists, while still allowing an upstream email change
|
|
579
|
+
to reuse a single unambiguous identity row:
|
|
580
|
+
|
|
581
|
+
1. The oldest row with the incoming email.
|
|
582
|
+
2. The only identity row when no email match exists.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
stmt = select(Account).where(Account.chatgpt_account_id == chatgpt_account_id)
|
|
586
|
+
order_by: list[Any] = [Account.created_at.asc(), Account.id.asc()]
|
|
587
|
+
if workspace_id:
|
|
588
|
+
stmt = stmt.where(or_(Account.workspace_id == workspace_id, Account.workspace_id.is_(None)))
|
|
589
|
+
order_by.insert(0, Account.workspace_id.is_(None).asc())
|
|
590
|
+
else:
|
|
591
|
+
stmt = stmt.where(Account.workspace_id.is_(None))
|
|
592
|
+
|
|
593
|
+
result = await self._session.execute(stmt.order_by(*order_by))
|
|
594
|
+
candidates = list(result.scalars().all())
|
|
595
|
+
if not candidates:
|
|
596
|
+
return None
|
|
597
|
+
if not email:
|
|
598
|
+
return candidates[0]
|
|
599
|
+
|
|
600
|
+
for candidate in candidates:
|
|
601
|
+
if candidate.email == email:
|
|
602
|
+
return candidate
|
|
603
|
+
|
|
604
|
+
if len(candidates) == 1:
|
|
605
|
+
return candidates[0]
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
async def _reconcile_chatgpt_identity_duplicates(
|
|
609
|
+
self,
|
|
610
|
+
canonical: Account,
|
|
611
|
+
chatgpt_account_id: str,
|
|
612
|
+
workspace_id: str | None,
|
|
613
|
+
email: str | None,
|
|
614
|
+
) -> bool:
|
|
615
|
+
duplicate_stmt = select(Account.id).where(
|
|
616
|
+
Account.chatgpt_account_id == chatgpt_account_id,
|
|
617
|
+
Account.id != canonical.id,
|
|
618
|
+
)
|
|
619
|
+
if email:
|
|
620
|
+
duplicate_stmt = duplicate_stmt.where(Account.email == email)
|
|
621
|
+
if workspace_id is None:
|
|
622
|
+
duplicate_stmt = duplicate_stmt.where(Account.workspace_id.is_(None))
|
|
623
|
+
else:
|
|
624
|
+
duplicate_stmt = duplicate_stmt.where(Account.workspace_id == workspace_id)
|
|
625
|
+
duplicate_accounts = (await self._session.execute(duplicate_stmt)).scalars().all()
|
|
626
|
+
duplicate_ids = list(duplicate_accounts)
|
|
627
|
+
if not duplicate_ids:
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
# Serialize against fold passes before reassigning any request logs:
|
|
631
|
+
# a fold overlapping this transaction could otherwise attribute the
|
|
632
|
+
# duplicates' logs to rollup rows this transaction deletes, leaving
|
|
633
|
+
# those logs behind the watermark but counted in no rollup.
|
|
634
|
+
await lock_fold_state(self._session)
|
|
635
|
+
|
|
636
|
+
duplicate_api_key_ids = (
|
|
637
|
+
(
|
|
638
|
+
await self._session.execute(
|
|
639
|
+
select(ApiKeyAccountAssignment.api_key_id).where(ApiKeyAccountAssignment.account_id == canonical.id)
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
.scalars()
|
|
643
|
+
.all()
|
|
644
|
+
)
|
|
645
|
+
existing_api_key_ids = set(duplicate_api_key_ids)
|
|
646
|
+
|
|
647
|
+
duplicate_assignments = (
|
|
648
|
+
(
|
|
649
|
+
await self._session.execute(
|
|
650
|
+
select(ApiKeyAccountAssignment).where(ApiKeyAccountAssignment.account_id.in_(duplicate_ids))
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
.scalars()
|
|
654
|
+
.all()
|
|
655
|
+
)
|
|
656
|
+
for assignment in duplicate_assignments:
|
|
657
|
+
if assignment.api_key_id in existing_api_key_ids:
|
|
658
|
+
await self._session.delete(assignment)
|
|
659
|
+
else:
|
|
660
|
+
assignment.account_id = canonical.id
|
|
661
|
+
existing_api_key_ids.add(assignment.api_key_id)
|
|
662
|
+
|
|
663
|
+
await self._session.execute(
|
|
664
|
+
update(UsageHistory).where(UsageHistory.account_id.in_(duplicate_ids)).values(account_id=canonical.id)
|
|
665
|
+
)
|
|
666
|
+
await self._session.execute(
|
|
667
|
+
update(AdditionalUsageHistory)
|
|
668
|
+
.where(AdditionalUsageHistory.account_id.in_(duplicate_ids))
|
|
669
|
+
.values(account_id=canonical.id)
|
|
670
|
+
)
|
|
671
|
+
await self._session.execute(
|
|
672
|
+
update(RequestLog).where(RequestLog.account_id.in_(duplicate_ids)).values(account_id=canonical.id)
|
|
673
|
+
)
|
|
674
|
+
await self._reconcile_limit_warmups(canonical.id, duplicate_ids)
|
|
675
|
+
await self._session.execute(
|
|
676
|
+
update(StickySession).where(StickySession.account_id.in_(duplicate_ids)).values(account_id=canonical.id)
|
|
677
|
+
)
|
|
678
|
+
await self._session.execute(
|
|
679
|
+
update(HttpBridgeSessionRecord)
|
|
680
|
+
.where(HttpBridgeSessionRecord.account_id.in_(duplicate_ids))
|
|
681
|
+
.values(account_id=canonical.id)
|
|
682
|
+
)
|
|
683
|
+
# Folded usage must follow the reassigned request logs, or the
|
|
684
|
+
# canonical account silently loses the duplicates' pre-watermark
|
|
685
|
+
# history from its lifetime totals.
|
|
686
|
+
await merge_rollups_into(self._session, canonical.id, duplicate_ids)
|
|
687
|
+
# Folded time-axis buckets follow the reassigned request logs too:
|
|
688
|
+
# bucket-wise merge-add onto the canonical account, duplicates
|
|
689
|
+
# removed, in this same fold-state-locked transaction.
|
|
690
|
+
await merge_time_rollups_into(self._session, canonical.id, duplicate_ids)
|
|
691
|
+
await self._session.execute(delete(Account).where(Account.id.in_(duplicate_ids)))
|
|
692
|
+
return True
|
|
693
|
+
|
|
694
|
+
async def _reconcile_limit_warmups(self, canonical_account_id: str, duplicate_ids: list[str]) -> None:
|
|
695
|
+
existing_keys = {
|
|
696
|
+
(window, reset_at)
|
|
697
|
+
for window, reset_at in (
|
|
698
|
+
await self._session.execute(
|
|
699
|
+
select(AccountLimitWarmup.window, AccountLimitWarmup.reset_at).where(
|
|
700
|
+
AccountLimitWarmup.account_id == canonical_account_id
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
).all()
|
|
704
|
+
}
|
|
705
|
+
duplicate_warmups = (
|
|
706
|
+
(
|
|
707
|
+
await self._session.execute(
|
|
708
|
+
select(AccountLimitWarmup).where(AccountLimitWarmup.account_id.in_(duplicate_ids))
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
.scalars()
|
|
712
|
+
.all()
|
|
713
|
+
)
|
|
714
|
+
for warmup in duplicate_warmups:
|
|
715
|
+
key = (warmup.window, warmup.reset_at)
|
|
716
|
+
if key in existing_keys:
|
|
717
|
+
await self._session.delete(warmup)
|
|
718
|
+
else:
|
|
719
|
+
warmup.account_id = canonical_account_id
|
|
720
|
+
existing_keys.add(key)
|
|
721
|
+
|
|
722
|
+
async def update_status(
|
|
723
|
+
self,
|
|
724
|
+
account_id: str,
|
|
725
|
+
status: AccountStatus,
|
|
726
|
+
deactivation_reason: str | None = None,
|
|
727
|
+
reset_at: int | None = None,
|
|
728
|
+
blocked_at: int | None | object = _UNSET,
|
|
729
|
+
) -> bool:
|
|
730
|
+
async with sqlite_writer_section():
|
|
731
|
+
previous_status = await self._session.scalar(
|
|
732
|
+
select(Account.status).where(Account.id == account_id).with_for_update()
|
|
733
|
+
)
|
|
734
|
+
values: dict[str, object | None] = {
|
|
735
|
+
"status": status,
|
|
736
|
+
"deactivation_reason": deactivation_reason,
|
|
737
|
+
"reset_at": reset_at,
|
|
738
|
+
}
|
|
739
|
+
if blocked_at is not _UNSET:
|
|
740
|
+
values["blocked_at"] = blocked_at
|
|
741
|
+
result = await self._session.execute(
|
|
742
|
+
update(Account)
|
|
743
|
+
.where(Account.id == account_id)
|
|
744
|
+
# An account marked for background deletion is terminal: a
|
|
745
|
+
# stale in-flight settlement (e.g. a 429 from a request that
|
|
746
|
+
# was selected before the DELETE) must not replace the
|
|
747
|
+
# DEACTIVATED/pending_deletion state and make the account
|
|
748
|
+
# selectable again mid-drain. Only a credential replacement
|
|
749
|
+
# (which clears the marker) may resurrect the row.
|
|
750
|
+
.where(Account.delete_requested_at.is_(None))
|
|
751
|
+
.values(**values)
|
|
752
|
+
.returning(Account.id)
|
|
753
|
+
)
|
|
754
|
+
updated_id = result.scalar_one_or_none()
|
|
755
|
+
if updated_id is not None and self._hard_sticky_outage_started(previous_status, status):
|
|
756
|
+
await self._refresh_hard_sticky_outage_grace(account_id)
|
|
757
|
+
if updated_id is not None and status == AccountStatus.DEACTIVATED:
|
|
758
|
+
await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
|
|
759
|
+
await self._close_http_bridge_sessions_for_account(account_id)
|
|
760
|
+
await self._session.commit()
|
|
761
|
+
return updated_id is not None
|
|
762
|
+
|
|
763
|
+
async def update_security_work_authorized(self, account_id: str, enabled: bool) -> bool:
|
|
764
|
+
async with sqlite_writer_section():
|
|
765
|
+
result = await self._session.execute(
|
|
766
|
+
update(Account)
|
|
767
|
+
.where(Account.id == account_id)
|
|
768
|
+
# Marked-for-deletion rows are gone from the operator's
|
|
769
|
+
# perspective: ID-based mutations must report not-found, as the
|
|
770
|
+
# synchronous delete did once the row was removed.
|
|
771
|
+
.where(Account.delete_requested_at.is_(None))
|
|
772
|
+
.values(security_work_authorized=enabled)
|
|
773
|
+
.returning(Account.id)
|
|
774
|
+
)
|
|
775
|
+
await self._session.commit()
|
|
776
|
+
return result.scalar_one_or_none() is not None
|
|
777
|
+
|
|
778
|
+
async def update_status_if_current(
|
|
779
|
+
self,
|
|
780
|
+
account_id: str,
|
|
781
|
+
status: AccountStatus,
|
|
782
|
+
deactivation_reason: str | None = None,
|
|
783
|
+
reset_at: int | None = None,
|
|
784
|
+
blocked_at: int | None | object = _UNSET,
|
|
785
|
+
*,
|
|
786
|
+
expected_status: AccountStatus,
|
|
787
|
+
expected_deactivation_reason: str | None = None,
|
|
788
|
+
expected_reset_at: int | None = None,
|
|
789
|
+
expected_blocked_at: int | None | object = _UNSET,
|
|
790
|
+
expected_refresh_token_encrypted: bytes | None = None,
|
|
791
|
+
) -> bool:
|
|
792
|
+
async with sqlite_writer_section():
|
|
793
|
+
values: dict[str, object | None] = {
|
|
794
|
+
"status": status,
|
|
795
|
+
"deactivation_reason": deactivation_reason,
|
|
796
|
+
"reset_at": reset_at,
|
|
797
|
+
}
|
|
798
|
+
if blocked_at is not _UNSET:
|
|
799
|
+
values["blocked_at"] = blocked_at
|
|
800
|
+
stmt = (
|
|
801
|
+
update(Account)
|
|
802
|
+
.where(Account.id == account_id)
|
|
803
|
+
.where(Account.status == expected_status)
|
|
804
|
+
# Same pending-deletion fence as ``update_status``: marked
|
|
805
|
+
# rows are terminal for ordinary status writers.
|
|
806
|
+
.where(Account.delete_requested_at.is_(None))
|
|
807
|
+
.values(**values)
|
|
808
|
+
.returning(Account.id)
|
|
809
|
+
)
|
|
810
|
+
if expected_deactivation_reason is None:
|
|
811
|
+
stmt = stmt.where(Account.deactivation_reason.is_(None))
|
|
812
|
+
else:
|
|
813
|
+
stmt = stmt.where(Account.deactivation_reason == expected_deactivation_reason)
|
|
814
|
+
if expected_reset_at is None:
|
|
815
|
+
stmt = stmt.where(Account.reset_at.is_(None))
|
|
816
|
+
else:
|
|
817
|
+
stmt = stmt.where(Account.reset_at == expected_reset_at)
|
|
818
|
+
if expected_blocked_at is not _UNSET:
|
|
819
|
+
if expected_blocked_at is None:
|
|
820
|
+
stmt = stmt.where(Account.blocked_at.is_(None))
|
|
821
|
+
else:
|
|
822
|
+
stmt = stmt.where(Account.blocked_at == expected_blocked_at)
|
|
823
|
+
if expected_refresh_token_encrypted is not None:
|
|
824
|
+
# Guards permanent refresh-failure downgrades: a concurrent
|
|
825
|
+
# re-auth/import rotates the token ciphertext without touching
|
|
826
|
+
# status/reason/reset, and this write must lose that race.
|
|
827
|
+
stmt = stmt.where(Account.refresh_token_encrypted == expected_refresh_token_encrypted)
|
|
828
|
+
result = await self._session.execute(stmt)
|
|
829
|
+
updated_id = result.scalar_one_or_none()
|
|
830
|
+
if updated_id is not None and self._hard_sticky_outage_started(expected_status, status):
|
|
831
|
+
await self._refresh_hard_sticky_outage_grace(account_id)
|
|
832
|
+
if updated_id is not None and status == AccountStatus.DEACTIVATED:
|
|
833
|
+
await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
|
|
834
|
+
await self._close_http_bridge_sessions_for_account(account_id)
|
|
835
|
+
await self._session.commit()
|
|
836
|
+
return updated_id is not None
|
|
837
|
+
|
|
838
|
+
@staticmethod
|
|
839
|
+
def _hard_sticky_outage_started(
|
|
840
|
+
previous_status: AccountStatus | None,
|
|
841
|
+
status: AccountStatus,
|
|
842
|
+
) -> bool:
|
|
843
|
+
return (
|
|
844
|
+
previous_status is not None
|
|
845
|
+
and previous_status not in _HARD_STICKY_UNAVAILABLE_STATUSES
|
|
846
|
+
and status in _HARD_STICKY_UNAVAILABLE_STATUSES
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
async def _refresh_hard_sticky_outage_grace(self, account_id: str) -> None:
|
|
850
|
+
"""Start a fresh purge grace period when a hard owner goes unavailable."""
|
|
851
|
+
|
|
852
|
+
await self._session.execute(
|
|
853
|
+
update(StickySession)
|
|
854
|
+
.where(
|
|
855
|
+
StickySession.account_id == account_id,
|
|
856
|
+
StickySession.kind == StickySessionKind.CODEX_SESSION,
|
|
857
|
+
)
|
|
858
|
+
.values(updated_at=utcnow())
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
async def seed_hard_sticky_outage_grace_on_startup(self) -> int:
|
|
862
|
+
"""Backfill, exactly once ever, a grace window for already-unavailable owners.
|
|
863
|
+
|
|
864
|
+
``_refresh_hard_sticky_outage_grace`` only fires on a live status
|
|
865
|
+
transition into PAUSED/RATE_LIMITED/QUOTA_EXCEEDED, so it never runs
|
|
866
|
+
for an account that was already sitting in one of those statuses
|
|
867
|
+
before this process started (e.g. an outage that began minutes
|
|
868
|
+
before a deploy). Without this, the purge scheduler's very first
|
|
869
|
+
cleanup cycle after this feature ships could treat that mapping's
|
|
870
|
+
stale ``updated_at`` as proof of a long-dead owner and purge it, even
|
|
871
|
+
though the outage is brand new — violating the "merely transient
|
|
872
|
+
outage is never purged" invariant for the upgrade window.
|
|
873
|
+
|
|
874
|
+
That backfill only needs to happen once per database, not once per
|
|
875
|
+
process start. All replicas share one database, and reseeding on
|
|
876
|
+
every boot resets the grace clock for accounts that are still
|
|
877
|
+
(correctly) unavailable; if deploys or autoscaling cycle faster than
|
|
878
|
+
the purge cutoff, a durably-dead mapping's grace clock would never
|
|
879
|
+
run out and it would never be purged. ``runtime_sentinels`` gives
|
|
880
|
+
every replica a shared, durable "has this ever run" marker: the
|
|
881
|
+
first replica to atomically stamp
|
|
882
|
+
``_HARD_STICKY_OUTAGE_GRACE_SEEDED_SENTINEL`` performs the backfill;
|
|
883
|
+
every later boot, on this or any other replica, finds the sentinel
|
|
884
|
+
already stamped and skips it, leaving the live per-transition hook
|
|
885
|
+
as the sole grace-clock source from then on.
|
|
886
|
+
"""
|
|
887
|
+
dialect = self._session.get_bind().dialect.name
|
|
888
|
+
if dialect == "postgresql":
|
|
889
|
+
insert_fn = pg_insert
|
|
890
|
+
elif dialect == "sqlite":
|
|
891
|
+
insert_fn = sqlite_insert
|
|
892
|
+
else:
|
|
893
|
+
raise RuntimeError(f"Hard-sticky outage grace seeding sentinel unsupported for dialect={dialect!r}")
|
|
894
|
+
stamp_stmt = (
|
|
895
|
+
insert_fn(RuntimeSentinel)
|
|
896
|
+
.values(name=_HARD_STICKY_OUTAGE_GRACE_SEEDED_SENTINEL, value=utcnow().isoformat())
|
|
897
|
+
.on_conflict_do_nothing(index_elements=[RuntimeSentinel.name])
|
|
898
|
+
.returning(RuntimeSentinel.name)
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
async def seed_once() -> int:
|
|
902
|
+
async with sqlite_writer_section():
|
|
903
|
+
stamp_result = await self._session.execute(stamp_stmt)
|
|
904
|
+
stamped_by_this_boot = stamp_result.scalar_one_or_none() is not None
|
|
905
|
+
if not stamped_by_this_boot:
|
|
906
|
+
await self._session.commit()
|
|
907
|
+
return 0
|
|
908
|
+
account_ids = (
|
|
909
|
+
await self._session.scalars(
|
|
910
|
+
select(Account.id).where(Account.status.in_(_HARD_STICKY_UNAVAILABLE_STATUSES))
|
|
911
|
+
)
|
|
912
|
+
).all()
|
|
913
|
+
for account_id in account_ids:
|
|
914
|
+
await self._refresh_hard_sticky_outage_grace(account_id)
|
|
915
|
+
await self._session.commit()
|
|
916
|
+
return len(account_ids)
|
|
917
|
+
|
|
918
|
+
try:
|
|
919
|
+
# This stamp is the first statement of its transaction on a
|
|
920
|
+
# startup-fresh connection, so it can simply lose SQLite's writer
|
|
921
|
+
# slot (issue #1949) — retry the whole attempt, rolling the failed
|
|
922
|
+
# transaction back first so the next one starts clean. Exhausting
|
|
923
|
+
# the budget still raises and fails startup, as it always has.
|
|
924
|
+
return await retry_on_sqlite_lock(
|
|
925
|
+
seed_once,
|
|
926
|
+
what="hard-sticky outage grace startup seed",
|
|
927
|
+
before_retry=self._session.rollback,
|
|
928
|
+
)
|
|
929
|
+
except OperationalError as exc:
|
|
930
|
+
if not _is_missing_hard_sticky_seed_table(exc):
|
|
931
|
+
raise
|
|
932
|
+
await self._session.rollback()
|
|
933
|
+
return 0
|
|
934
|
+
|
|
935
|
+
async def _close_http_bridge_sessions_for_account(self, account_id: str) -> None:
|
|
936
|
+
session_ids = select(HttpBridgeSessionRecord.id).where(HttpBridgeSessionRecord.account_id == account_id)
|
|
937
|
+
await self._session.execute(
|
|
938
|
+
delete(HttpBridgeSessionAlias).where(HttpBridgeSessionAlias.session_id.in_(session_ids))
|
|
939
|
+
)
|
|
940
|
+
await self._session.execute(
|
|
941
|
+
update(HttpBridgeSessionRecord)
|
|
942
|
+
.where(HttpBridgeSessionRecord.account_id == account_id)
|
|
943
|
+
.values(
|
|
944
|
+
account_id=None,
|
|
945
|
+
state=HttpBridgeSessionState.CLOSED,
|
|
946
|
+
closed_at=utcnow(),
|
|
947
|
+
owner_instance_id=None,
|
|
948
|
+
lease_expires_at=None,
|
|
949
|
+
latest_turn_state=None,
|
|
950
|
+
latest_response_id=None,
|
|
951
|
+
latest_input_item_count=None,
|
|
952
|
+
latest_input_full_fingerprint=None,
|
|
953
|
+
latest_pending_tool_calls_json=None,
|
|
954
|
+
)
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
async def update_alias(self, account_id: str, alias: str | None) -> bool:
|
|
958
|
+
async with sqlite_writer_section():
|
|
959
|
+
result = await self._session.execute(
|
|
960
|
+
update(Account)
|
|
961
|
+
.where(Account.id == account_id)
|
|
962
|
+
# Marked-for-deletion rows are gone from the operator's
|
|
963
|
+
# perspective: ID-based mutations must report not-found, as the
|
|
964
|
+
# synchronous delete did once the row was removed.
|
|
965
|
+
.where(Account.delete_requested_at.is_(None))
|
|
966
|
+
.values(alias=alias)
|
|
967
|
+
.returning(Account.id)
|
|
968
|
+
)
|
|
969
|
+
await self._session.commit()
|
|
970
|
+
return result.scalar_one_or_none() is not None
|
|
971
|
+
|
|
972
|
+
async def update_limit_warmup_enabled(self, account_id: str, enabled: bool) -> bool:
|
|
973
|
+
async with sqlite_writer_section():
|
|
974
|
+
result = await self._session.execute(
|
|
975
|
+
update(Account)
|
|
976
|
+
.where(Account.id == account_id)
|
|
977
|
+
# Marked-for-deletion rows are gone from the operator's
|
|
978
|
+
# perspective: ID-based mutations must report not-found, as the
|
|
979
|
+
# synchronous delete did once the row was removed.
|
|
980
|
+
.where(Account.delete_requested_at.is_(None))
|
|
981
|
+
.values(limit_warmup_enabled=enabled)
|
|
982
|
+
.returning(Account.id)
|
|
983
|
+
)
|
|
984
|
+
await self._session.commit()
|
|
985
|
+
return result.scalar_one_or_none() is not None
|
|
986
|
+
|
|
987
|
+
async def update_routing_policy(self, account_id: str, routing_policy: str) -> bool:
|
|
988
|
+
async with sqlite_writer_section():
|
|
989
|
+
result = await self._session.execute(
|
|
990
|
+
update(Account)
|
|
991
|
+
.where(Account.id == account_id)
|
|
992
|
+
# Marked-for-deletion rows are gone from the operator's
|
|
993
|
+
# perspective: ID-based mutations must report not-found, as the
|
|
994
|
+
# synchronous delete did once the row was removed.
|
|
995
|
+
.where(Account.delete_requested_at.is_(None))
|
|
996
|
+
.values(routing_policy=routing_policy)
|
|
997
|
+
.returning(Account.id)
|
|
998
|
+
)
|
|
999
|
+
await self._session.commit()
|
|
1000
|
+
return result.scalar_one_or_none() is not None
|
|
1001
|
+
|
|
1002
|
+
async def begin_delete(self, account_id: str, *, delete_history: bool = False) -> bool:
|
|
1003
|
+
"""Mark an account for background deletion; commits in milliseconds.
|
|
1004
|
+
|
|
1005
|
+
Fast path of ``DELETE /api/accounts/{id}``: the account becomes
|
|
1006
|
+
terminal (``DEACTIVATED`` — every serving path already excludes it)
|
|
1007
|
+
and carries the pending-deletion marker that hides it from listings
|
|
1008
|
+
and enqueues it for the deletion worker, which drains its bulk rows
|
|
1009
|
+
in chunks and finalizes via :meth:`delete` with ``only_pending=True``.
|
|
1010
|
+
|
|
1011
|
+
The stored token ciphertext is overwritten with empty-credential
|
|
1012
|
+
ciphertext in the same transaction: the row outlives the DELETE
|
|
1013
|
+
response by the drain duration, and no reader — including a
|
|
1014
|
+
pre-upgrade replica during a rolling deploy, whose export endpoints
|
|
1015
|
+
do not know the marker — may still be able to produce usable
|
|
1016
|
+
credentials from it. A credential replacement (the only supersede
|
|
1017
|
+
path) writes fresh ciphertext, and token rotation is CAS-guarded on
|
|
1018
|
+
the pre-wipe refresh ciphertext, so a stale in-flight rotation
|
|
1019
|
+
misses rather than resurrecting the old material. Before the wipe,
|
|
1020
|
+
the non-secret seat identity is preserved: legacy rows whose
|
|
1021
|
+
``chatgpt_user_id`` was never backfilled carry it only inside the
|
|
1022
|
+
id-token claims, and targeted reauthentication — the promised
|
|
1023
|
+
supersede path — verifies the seat against exactly those two
|
|
1024
|
+
sources, so ``chatgpt_user_id`` is backfilled from the claims when
|
|
1025
|
+
absent.
|
|
1026
|
+
|
|
1027
|
+
API-key account assignments are removed here as well (the FK cascade
|
|
1028
|
+
used to do this when the synchronous delete removed the row), so key
|
|
1029
|
+
listings and pooled-usage projections exclude the account
|
|
1030
|
+
immediately; the key's ``account_assignment_scope_enabled`` flag is
|
|
1031
|
+
persisted separately and keeps the key scoped.
|
|
1032
|
+
|
|
1033
|
+
Idempotent: a repeat request on an already-marked account succeeds
|
|
1034
|
+
without changing the frozen ``delete_history`` choice (first request
|
|
1035
|
+
wins — matching the synchronous behavior, where a second DELETE after
|
|
1036
|
+
the first completed found nothing left to escalate).
|
|
1037
|
+
"""
|
|
1038
|
+
# Repeat requests short-circuit BEFORE the writer section / row lock:
|
|
1039
|
+
# a drain chunk holds the account row (and, on SQLite, the writer
|
|
1040
|
+
# section) for up to a few seconds, and the fast-path contract is a
|
|
1041
|
+
# millisecond-scale response. The unlocked read is safe because the
|
|
1042
|
+
# repeat changes nothing — the first request froze the variant and
|
|
1043
|
+
# the wipe/cleanup already ran — and a replacement racing this read
|
|
1044
|
+
# supersedes the deletion exactly as if it landed after this
|
|
1045
|
+
# response. When credentials were replaced WITHOUT clearing the
|
|
1046
|
+
# marker (a pre-upgrade replica's replacement), fall through to the
|
|
1047
|
+
# full path so an explicit re-delete re-wipes and re-arms.
|
|
1048
|
+
marked_row = (
|
|
1049
|
+
await self._session.execute(
|
|
1050
|
+
select(
|
|
1051
|
+
Account.delete_requested_at,
|
|
1052
|
+
Account.access_token_encrypted,
|
|
1053
|
+
Account.refresh_token_encrypted,
|
|
1054
|
+
Account.id_token_encrypted,
|
|
1055
|
+
).where(Account.id == account_id)
|
|
1056
|
+
)
|
|
1057
|
+
).first()
|
|
1058
|
+
if (
|
|
1059
|
+
marked_row is not None
|
|
1060
|
+
and marked_row[0] is not None
|
|
1061
|
+
and not credentials_replaced_since_wipe(marked_row[1], marked_row[2], marked_row[3])
|
|
1062
|
+
):
|
|
1063
|
+
return True
|
|
1064
|
+
encryptor = TokenEncryptor()
|
|
1065
|
+
wiped_token = encryptor.encrypt("")
|
|
1066
|
+
async with sqlite_writer_section():
|
|
1067
|
+
seat_stmt = select(Account.chatgpt_user_id, Account.id_token_encrypted).where(Account.id == account_id)
|
|
1068
|
+
if self._dialect_name() == "postgresql":
|
|
1069
|
+
# Hold the row through the mark so the derived seat identity
|
|
1070
|
+
# cannot go stale between this read and the update below.
|
|
1071
|
+
seat_stmt = seat_stmt.with_for_update(key_share=True)
|
|
1072
|
+
seat_row = (await self._session.execute(seat_stmt)).first()
|
|
1073
|
+
if seat_row is None:
|
|
1074
|
+
await self._session.rollback()
|
|
1075
|
+
return False
|
|
1076
|
+
seat_user_id: str | None = seat_row[0]
|
|
1077
|
+
if seat_user_id is None:
|
|
1078
|
+
try:
|
|
1079
|
+
claims = extract_id_token_claims(encryptor.decrypt(seat_row[1]))
|
|
1080
|
+
seat_user_id = resolve_seat_identity(claims, claims.auth)
|
|
1081
|
+
except Exception:
|
|
1082
|
+
seat_user_id = None
|
|
1083
|
+
values: dict[str, Any] = {
|
|
1084
|
+
"status": AccountStatus.DEACTIVATED,
|
|
1085
|
+
"deactivation_reason": ACCOUNT_PENDING_DELETION_REASON,
|
|
1086
|
+
"reset_at": None,
|
|
1087
|
+
"blocked_at": None,
|
|
1088
|
+
"access_token_encrypted": wiped_token,
|
|
1089
|
+
"refresh_token_encrypted": wiped_token,
|
|
1090
|
+
"id_token_encrypted": wiped_token,
|
|
1091
|
+
"delete_requested_at": func.coalesce(Account.delete_requested_at, utcnow()),
|
|
1092
|
+
"delete_history_requested": case(
|
|
1093
|
+
(Account.delete_requested_at.is_(None), delete_history),
|
|
1094
|
+
else_=Account.delete_history_requested,
|
|
1095
|
+
),
|
|
1096
|
+
}
|
|
1097
|
+
if seat_user_id is not None:
|
|
1098
|
+
values["chatgpt_user_id"] = seat_user_id
|
|
1099
|
+
result = await self._session.execute(
|
|
1100
|
+
update(Account).where(Account.id == account_id).values(**values).returning(Account.id)
|
|
1101
|
+
)
|
|
1102
|
+
updated_id = result.scalar_one_or_none()
|
|
1103
|
+
if updated_id is not None:
|
|
1104
|
+
# Same immediate cleanup the DEACTIVATED transition performs:
|
|
1105
|
+
# sticky mappings and bridge sessions must not outlive the
|
|
1106
|
+
# account's routability.
|
|
1107
|
+
await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
|
|
1108
|
+
await self._close_http_bridge_sessions_for_account(account_id)
|
|
1109
|
+
await self._session.execute(
|
|
1110
|
+
delete(ApiKeyAccountAssignment).where(ApiKeyAccountAssignment.account_id == account_id)
|
|
1111
|
+
)
|
|
1112
|
+
await self._session.commit()
|
|
1113
|
+
return updated_id is not None
|
|
1114
|
+
|
|
1115
|
+
async def delete(
|
|
1116
|
+
self,
|
|
1117
|
+
account_id: str,
|
|
1118
|
+
*,
|
|
1119
|
+
delete_history: bool = False,
|
|
1120
|
+
only_pending: bool = False,
|
|
1121
|
+
) -> bool:
|
|
1122
|
+
async with sqlite_writer_section():
|
|
1123
|
+
if self._dialect_name() == "postgresql":
|
|
1124
|
+
# Identity membership precedes the fold-state lock so live
|
|
1125
|
+
# settlement and deletion cannot form an identity/fold cycle.
|
|
1126
|
+
locked_account = await self._lock_postgresql_account_identity_membership(account_id, None)
|
|
1127
|
+
pending_state = (
|
|
1128
|
+
None
|
|
1129
|
+
if locked_account is None
|
|
1130
|
+
else (
|
|
1131
|
+
locked_account.delete_requested_at,
|
|
1132
|
+
locked_account.delete_history_requested,
|
|
1133
|
+
locked_account.access_token_encrypted,
|
|
1134
|
+
locked_account.refresh_token_encrypted,
|
|
1135
|
+
locked_account.id_token_encrypted,
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
else:
|
|
1139
|
+
pending_state = (
|
|
1140
|
+
await self._session.execute(
|
|
1141
|
+
select(
|
|
1142
|
+
Account.delete_requested_at,
|
|
1143
|
+
Account.delete_history_requested,
|
|
1144
|
+
Account.access_token_encrypted,
|
|
1145
|
+
Account.refresh_token_encrypted,
|
|
1146
|
+
Account.id_token_encrypted,
|
|
1147
|
+
).where(Account.id == account_id)
|
|
1148
|
+
)
|
|
1149
|
+
).first()
|
|
1150
|
+
if only_pending:
|
|
1151
|
+
# Background finalization: a credential replacement
|
|
1152
|
+
# (re-import/reauth) that cleared the marker supersedes the
|
|
1153
|
+
# deletion, so touch nothing. The variant comes from the
|
|
1154
|
+
# persisted flag frozen at request time, never the caller.
|
|
1155
|
+
# On PostgreSQL the identity-membership row lock held above
|
|
1156
|
+
# keeps the marker stable through this transaction; on SQLite
|
|
1157
|
+
# the writer section serializes all writers.
|
|
1158
|
+
if pending_state is None or pending_state[0] is None:
|
|
1159
|
+
await self._session.rollback()
|
|
1160
|
+
return False
|
|
1161
|
+
if credentials_replaced_since_wipe(pending_state[2], pending_state[3], pending_state[4]):
|
|
1162
|
+
# A pre-upgrade replica replaced the credentials without
|
|
1163
|
+
# being able to clear marker columns its ORM does not
|
|
1164
|
+
# know. That replacement supersedes the deletion: clear
|
|
1165
|
+
# the marker (we hold the row lock) and abandon.
|
|
1166
|
+
await self._session.execute(
|
|
1167
|
+
update(Account)
|
|
1168
|
+
.where(Account.id == account_id)
|
|
1169
|
+
.values(delete_requested_at=None, delete_history_requested=False)
|
|
1170
|
+
)
|
|
1171
|
+
await self._session.commit()
|
|
1172
|
+
return False
|
|
1173
|
+
delete_history = bool(pending_state[1])
|
|
1174
|
+
# Serialize against fold passes before touching the account's
|
|
1175
|
+
# request logs: without the fold-state lock an in-flight hourly
|
|
1176
|
+
# slice could aggregate the pre-delete attribution but commit
|
|
1177
|
+
# after this transaction, resurrecting the account's folded rows
|
|
1178
|
+
# the mirrors below just moved or removed.
|
|
1179
|
+
await lock_fold_state(self._session)
|
|
1180
|
+
if self._dialect_name() == "postgresql":
|
|
1181
|
+
# Upgrade the account row to a full FOR UPDATE lock BEFORE the
|
|
1182
|
+
# raw sweeps. FOR UPDATE conflicts with the KEY SHARE taken by
|
|
1183
|
+
# concurrent request-log FK inserts, so every in-flight
|
|
1184
|
+
# stream's log row either commits before this point (and the
|
|
1185
|
+
# sweeps below see it) or its insert blocks until this
|
|
1186
|
+
# transaction commits and then fails its FK against the
|
|
1187
|
+
# deleted row — the same outcome a post-delete insert always
|
|
1188
|
+
# had. Without the upgrade, an insert could commit between
|
|
1189
|
+
# the sweep and the account-row delete: the FK's ON DELETE
|
|
1190
|
+
# SET NULL would leave a live (deleted_at IS NULL) orphan on
|
|
1191
|
+
# the soft path, or surviving raw history under
|
|
1192
|
+
# delete_history. Lock order (identity -> fold -> row
|
|
1193
|
+
# exclusive) matches the historical transaction, where the
|
|
1194
|
+
# final DELETE acquired this same exclusive lock after the
|
|
1195
|
+
# fold lock.
|
|
1196
|
+
await self._session.execute(select(Account.id).where(Account.id == account_id).with_for_update())
|
|
1197
|
+
await self._session.execute(delete(UsageHistory).where(UsageHistory.account_id == account_id))
|
|
1198
|
+
if delete_history:
|
|
1199
|
+
await self._session.execute(delete(RequestLog).where(RequestLog.account_id == account_id))
|
|
1200
|
+
# Mirror the raw DELETE into the folded time-axis buckets;
|
|
1201
|
+
# raw below the watermark may already be pruned, so folded
|
|
1202
|
+
# rows can never be recomputed and must be removed directly.
|
|
1203
|
+
await mirror_account_hard_delete_into_time_rollups(self._session, account_id)
|
|
1204
|
+
else:
|
|
1205
|
+
await self._session.execute(
|
|
1206
|
+
update(RequestLog)
|
|
1207
|
+
.where(RequestLog.account_id == account_id)
|
|
1208
|
+
.values(account_id=None, deleted_at=utcnow()),
|
|
1209
|
+
)
|
|
1210
|
+
# Mirror the retroactive detach (account_id=NULL, deleted_at
|
|
1211
|
+
# set) into the folded buckets: move them to the orphaned
|
|
1212
|
+
# deleted dimension so time-series totals are preserved.
|
|
1213
|
+
await mirror_account_soft_delete_into_time_rollups(self._session, account_id)
|
|
1214
|
+
await self._session.execute(delete(StickySession).where(StickySession.account_id == account_id))
|
|
1215
|
+
await self._session.execute(delete(AccountUsageRollup).where(AccountUsageRollup.account_id == account_id))
|
|
1216
|
+
result = await self._session.execute(delete(Account).where(Account.id == account_id).returning(Account.id))
|
|
1217
|
+
deleted_id = result.scalar_one_or_none()
|
|
1218
|
+
await self._session.commit()
|
|
1219
|
+
if deleted_id is not None:
|
|
1220
|
+
_clear_bulk_history_since_sqlite_cache()
|
|
1221
|
+
# Deletion drops the account's rollup row and detaches or
|
|
1222
|
+
# deletes its request logs; cached listing summaries would
|
|
1223
|
+
# keep reporting the account until TTL expiry.
|
|
1224
|
+
_clear_request_usage_summary_cache()
|
|
1225
|
+
return deleted_id is not None
|
|
1226
|
+
|
|
1227
|
+
async def rotate_tokens(
|
|
1228
|
+
self,
|
|
1229
|
+
account_id: str,
|
|
1230
|
+
access_token_encrypted: bytes,
|
|
1231
|
+
refresh_token_encrypted: bytes,
|
|
1232
|
+
id_token_encrypted: bytes,
|
|
1233
|
+
last_refresh: datetime,
|
|
1234
|
+
*,
|
|
1235
|
+
expected_refresh_token_encrypted: bytes,
|
|
1236
|
+
plan_type: str | None = None,
|
|
1237
|
+
email: str | None = None,
|
|
1238
|
+
chatgpt_account_id: str | None = None,
|
|
1239
|
+
chatgpt_user_id: str | None = None,
|
|
1240
|
+
workspace_id: str | None = None,
|
|
1241
|
+
workspace_label: str | None = None,
|
|
1242
|
+
seat_type: str | None = None,
|
|
1243
|
+
) -> bool:
|
|
1244
|
+
"""Persist rotated access/refresh/id token ciphertext under a mandatory
|
|
1245
|
+
compare-and-set on the refresh-token ciphertext.
|
|
1246
|
+
|
|
1247
|
+
This is the ONLY method that writes token ciphertext, and the CAS
|
|
1248
|
+
predicate (``expected_refresh_token_encrypted``) is REQUIRED — there is
|
|
1249
|
+
no code path that writes ``refresh_token_encrypted`` unconditionally.
|
|
1250
|
+
The comparison is atomic in the database, so a concurrent rotation
|
|
1251
|
+
committed after the caller read ``expected`` makes this write MISS (it
|
|
1252
|
+
returns ``False`` and touches no row) rather than clobbering the peer's
|
|
1253
|
+
fresh material. Metadata is co-written here only because a genuine
|
|
1254
|
+
rotation carries a fresh identity/plan/workspace snapshot; metadata-only
|
|
1255
|
+
writers must use ``update_account_metadata`` (which cannot touch token
|
|
1256
|
+
material at all).
|
|
1257
|
+
"""
|
|
1258
|
+
async with sqlite_writer_section():
|
|
1259
|
+
if self._dialect_name() == "postgresql":
|
|
1260
|
+
await self._lock_postgresql_account_identity_membership(account_id, chatgpt_account_id)
|
|
1261
|
+
values: dict[str, bytes | datetime | str] = {
|
|
1262
|
+
"access_token_encrypted": access_token_encrypted,
|
|
1263
|
+
"refresh_token_encrypted": refresh_token_encrypted,
|
|
1264
|
+
"id_token_encrypted": id_token_encrypted,
|
|
1265
|
+
"last_refresh": last_refresh,
|
|
1266
|
+
}
|
|
1267
|
+
if plan_type is not None:
|
|
1268
|
+
values["plan_type"] = plan_type
|
|
1269
|
+
if email is not None:
|
|
1270
|
+
values["email"] = email
|
|
1271
|
+
if chatgpt_account_id is not None:
|
|
1272
|
+
values["chatgpt_account_id"] = chatgpt_account_id
|
|
1273
|
+
if chatgpt_user_id is not None:
|
|
1274
|
+
values["chatgpt_user_id"] = chatgpt_user_id
|
|
1275
|
+
if workspace_id is not None:
|
|
1276
|
+
values["workspace_id"] = workspace_id
|
|
1277
|
+
if workspace_label is not None:
|
|
1278
|
+
values["workspace_label"] = workspace_label
|
|
1279
|
+
if seat_type is not None:
|
|
1280
|
+
values["seat_type"] = seat_type
|
|
1281
|
+
stmt = (
|
|
1282
|
+
update(Account)
|
|
1283
|
+
.where(Account.id == account_id)
|
|
1284
|
+
# Compare-and-set on the exact ciphertext the caller read before
|
|
1285
|
+
# the upstream exchange, so a concurrent rotation is never
|
|
1286
|
+
# clobbered by a slower writer.
|
|
1287
|
+
.where(Account.refresh_token_encrypted == expected_refresh_token_encrypted)
|
|
1288
|
+
.values(**values)
|
|
1289
|
+
.returning(Account.id)
|
|
1290
|
+
)
|
|
1291
|
+
result = await self._session.execute(stmt)
|
|
1292
|
+
await self._session.commit()
|
|
1293
|
+
return result.scalar_one_or_none() is not None
|
|
1294
|
+
|
|
1295
|
+
async def update_account_metadata(
|
|
1296
|
+
self,
|
|
1297
|
+
account_id: str,
|
|
1298
|
+
*,
|
|
1299
|
+
plan_type: str | None = None,
|
|
1300
|
+
email: str | None = None,
|
|
1301
|
+
chatgpt_account_id: str | None = None,
|
|
1302
|
+
chatgpt_user_id: str | None = None,
|
|
1303
|
+
workspace_id: str | None = None,
|
|
1304
|
+
workspace_label: str | None = None,
|
|
1305
|
+
seat_type: str | None = None,
|
|
1306
|
+
last_refresh: datetime | None = None,
|
|
1307
|
+
) -> bool:
|
|
1308
|
+
"""Update non-token account metadata (identity/plan/workspace fields).
|
|
1309
|
+
|
|
1310
|
+
This method structurally CANNOT write access/refresh/id token
|
|
1311
|
+
ciphertext — there is no parameter for it — so a metadata-only writer
|
|
1312
|
+
holding a stale ``Account`` snapshot can never clobber a concurrent
|
|
1313
|
+
cross-replica token rotation. It is the correct path for backfills and
|
|
1314
|
+
identity syncs that must never touch token material. Only the provided
|
|
1315
|
+
(non-``None``) fields are written; a call with nothing to set is a
|
|
1316
|
+
no-op existence check.
|
|
1317
|
+
"""
|
|
1318
|
+
async with sqlite_writer_section():
|
|
1319
|
+
if self._dialect_name() == "postgresql":
|
|
1320
|
+
await self._lock_postgresql_account_identity_membership(account_id, chatgpt_account_id)
|
|
1321
|
+
values: dict[str, str | datetime] = {}
|
|
1322
|
+
if plan_type is not None:
|
|
1323
|
+
values["plan_type"] = plan_type
|
|
1324
|
+
if email is not None:
|
|
1325
|
+
values["email"] = email
|
|
1326
|
+
if chatgpt_account_id is not None:
|
|
1327
|
+
values["chatgpt_account_id"] = chatgpt_account_id
|
|
1328
|
+
if chatgpt_user_id is not None:
|
|
1329
|
+
values["chatgpt_user_id"] = chatgpt_user_id
|
|
1330
|
+
if workspace_id is not None:
|
|
1331
|
+
values["workspace_id"] = workspace_id
|
|
1332
|
+
if workspace_label is not None:
|
|
1333
|
+
values["workspace_label"] = workspace_label
|
|
1334
|
+
if seat_type is not None:
|
|
1335
|
+
values["seat_type"] = seat_type
|
|
1336
|
+
if last_refresh is not None:
|
|
1337
|
+
values["last_refresh"] = last_refresh
|
|
1338
|
+
if not values:
|
|
1339
|
+
existing = await self._session.get(Account, account_id)
|
|
1340
|
+
return existing is not None
|
|
1341
|
+
result = await self._session.execute(
|
|
1342
|
+
update(Account).where(Account.id == account_id).values(**values).returning(Account.id)
|
|
1343
|
+
)
|
|
1344
|
+
await self._session.commit()
|
|
1345
|
+
return result.scalar_one_or_none() is not None
|
|
1346
|
+
|
|
1347
|
+
async def workspace_slot_taken(
|
|
1348
|
+
self,
|
|
1349
|
+
*,
|
|
1350
|
+
account_id: str,
|
|
1351
|
+
email: str,
|
|
1352
|
+
chatgpt_account_id: str | None,
|
|
1353
|
+
workspace_id: str,
|
|
1354
|
+
) -> bool:
|
|
1355
|
+
if chatgpt_account_id:
|
|
1356
|
+
predicates = [
|
|
1357
|
+
(Account.chatgpt_account_id == chatgpt_account_id) & (Account.workspace_id == workspace_id),
|
|
1358
|
+
(
|
|
1359
|
+
(Account.email == email)
|
|
1360
|
+
& (Account.workspace_id == workspace_id)
|
|
1361
|
+
& Account.chatgpt_account_id.is_(None)
|
|
1362
|
+
),
|
|
1363
|
+
]
|
|
1364
|
+
else:
|
|
1365
|
+
predicates = [(Account.email == email) & (Account.workspace_id == workspace_id)]
|
|
1366
|
+
result = await self._session.execute(
|
|
1367
|
+
select(Account.id).where(Account.id != account_id).where(or_(*predicates)).limit(1)
|
|
1368
|
+
)
|
|
1369
|
+
return result.scalar_one_or_none() is not None
|
|
1370
|
+
|
|
1371
|
+
async def _merge_by_email_enabled(self) -> bool:
|
|
1372
|
+
settings = await self._session.get(DashboardSettings, _SETTINGS_ROW_ID)
|
|
1373
|
+
if settings is None:
|
|
1374
|
+
return True
|
|
1375
|
+
return not settings.import_without_overwrite
|
|
1376
|
+
|
|
1377
|
+
async def additional_quota_routing_policy_overrides(self) -> dict[str, str]:
|
|
1378
|
+
settings = await self._session.get(DashboardSettings, _SETTINGS_ROW_ID)
|
|
1379
|
+
if settings is None or not settings.additional_quota_routing_policies_json:
|
|
1380
|
+
return {}
|
|
1381
|
+
try:
|
|
1382
|
+
parsed = json.loads(settings.additional_quota_routing_policies_json)
|
|
1383
|
+
except json.JSONDecodeError:
|
|
1384
|
+
return {}
|
|
1385
|
+
if not isinstance(parsed, dict):
|
|
1386
|
+
return {}
|
|
1387
|
+
string_policies = {
|
|
1388
|
+
key: value for key, value in parsed.items() if isinstance(key, str) and isinstance(value, str)
|
|
1389
|
+
}
|
|
1390
|
+
return normalize_additional_quota_routing_policy_overrides(string_policies)
|
|
1391
|
+
|
|
1392
|
+
async def _next_available_account_id(self, base_id: str) -> str:
|
|
1393
|
+
candidate = base_id
|
|
1394
|
+
sequence = 2
|
|
1395
|
+
while await self._session.get(Account, candidate) is not None:
|
|
1396
|
+
candidate = f"{base_id}{_DUPLICATE_ACCOUNT_SUFFIX}{sequence}"
|
|
1397
|
+
sequence += 1
|
|
1398
|
+
return candidate
|
|
1399
|
+
|
|
1400
|
+
async def _single_account_by_email(self, email: str) -> Account | None:
|
|
1401
|
+
result = await self._session.execute(
|
|
1402
|
+
select(Account).where(Account.email == email).order_by(Account.created_at.asc(), Account.id.asc()).limit(2)
|
|
1403
|
+
)
|
|
1404
|
+
matches = list(result.scalars().all())
|
|
1405
|
+
if not matches:
|
|
1406
|
+
return None
|
|
1407
|
+
if len(matches) > 1:
|
|
1408
|
+
raise AccountIdentityConflictError(email)
|
|
1409
|
+
return matches[0]
|
|
1410
|
+
|
|
1411
|
+
async def _single_unknown_workspace_account_by_email(self, email: str) -> Account | None:
|
|
1412
|
+
result = await self._session.execute(
|
|
1413
|
+
select(Account)
|
|
1414
|
+
.where(Account.email == email)
|
|
1415
|
+
.where(Account.workspace_id.is_(None))
|
|
1416
|
+
.where(Account.workspace_label.is_(None))
|
|
1417
|
+
.order_by(Account.created_at.asc(), Account.id.asc())
|
|
1418
|
+
.limit(2)
|
|
1419
|
+
)
|
|
1420
|
+
matches = list(result.scalars().all())
|
|
1421
|
+
if not matches:
|
|
1422
|
+
return None
|
|
1423
|
+
if len(matches) > 1:
|
|
1424
|
+
raise AccountIdentityConflictError(email)
|
|
1425
|
+
return matches[0]
|
|
1426
|
+
|
|
1427
|
+
async def _account_by_slot_identity(self, account: Account) -> Account | None:
|
|
1428
|
+
workspace_slot = _workspace_slot_identity(account)
|
|
1429
|
+
if account.chatgpt_account_id and account.email and workspace_slot:
|
|
1430
|
+
column, value = workspace_slot
|
|
1431
|
+
result = await self._session.execute(
|
|
1432
|
+
select(Account)
|
|
1433
|
+
.where(Account.chatgpt_account_id == account.chatgpt_account_id)
|
|
1434
|
+
.where(Account.email == account.email)
|
|
1435
|
+
.where(column == value)
|
|
1436
|
+
.order_by(Account.created_at.asc(), Account.id.asc())
|
|
1437
|
+
.limit(1)
|
|
1438
|
+
)
|
|
1439
|
+
if matched := result.scalar_one_or_none():
|
|
1440
|
+
return matched
|
|
1441
|
+
if account.chatgpt_account_id and account.email and account.workspace_id and account.workspace_label:
|
|
1442
|
+
result = await self._session.execute(
|
|
1443
|
+
select(Account)
|
|
1444
|
+
.where(Account.chatgpt_account_id == account.chatgpt_account_id)
|
|
1445
|
+
.where(Account.email == account.email)
|
|
1446
|
+
.where(Account.workspace_id.is_(None))
|
|
1447
|
+
.where(Account.workspace_label == account.workspace_label)
|
|
1448
|
+
.order_by(Account.created_at.asc(), Account.id.asc())
|
|
1449
|
+
.limit(1)
|
|
1450
|
+
)
|
|
1451
|
+
if matched := result.scalar_one_or_none():
|
|
1452
|
+
return matched
|
|
1453
|
+
if workspace_slot and account.email:
|
|
1454
|
+
column, value = workspace_slot
|
|
1455
|
+
result = await self._session.execute(
|
|
1456
|
+
select(Account)
|
|
1457
|
+
.where(Account.email == account.email)
|
|
1458
|
+
.where(column == value)
|
|
1459
|
+
.order_by(Account.created_at.asc(), Account.id.asc())
|
|
1460
|
+
.limit(1)
|
|
1461
|
+
)
|
|
1462
|
+
matched = result.scalar_one_or_none()
|
|
1463
|
+
if matched is not None and _can_reuse_email_fallback(matched, account):
|
|
1464
|
+
return matched
|
|
1465
|
+
return None
|
|
1466
|
+
|
|
1467
|
+
async def _lock_postgresql_account_identity_membership(
|
|
1468
|
+
self,
|
|
1469
|
+
account_id: str,
|
|
1470
|
+
incoming_chatgpt_account_id: str | None,
|
|
1471
|
+
*,
|
|
1472
|
+
second_attempt: bool = False,
|
|
1473
|
+
) -> Account | None:
|
|
1474
|
+
"""Lock one row's old/new upstream memberships before mutating it."""
|
|
1475
|
+
observed_identity = await self._session.scalar(
|
|
1476
|
+
select(Account.chatgpt_account_id).where(Account.id == account_id)
|
|
1477
|
+
)
|
|
1478
|
+
await lock_postgresql_account_identities(
|
|
1479
|
+
self._session,
|
|
1480
|
+
(observed_identity, incoming_chatgpt_account_id),
|
|
1481
|
+
)
|
|
1482
|
+
locked_account = await self._session.scalar(
|
|
1483
|
+
select(Account)
|
|
1484
|
+
.where(Account.id == account_id)
|
|
1485
|
+
# PostgreSQL FOR NO KEY UPDATE stabilizes identity membership but
|
|
1486
|
+
# remains compatible with the KEY SHARE lock taken by concurrent
|
|
1487
|
+
# rollup FK inserts. Deletion upgrades only after the fold lock.
|
|
1488
|
+
.with_for_update(key_share=True)
|
|
1489
|
+
.execution_options(populate_existing=True)
|
|
1490
|
+
)
|
|
1491
|
+
locked_identity = locked_account.chatgpt_account_id if locked_account is not None else None
|
|
1492
|
+
if locked_identity == observed_identity:
|
|
1493
|
+
return locked_account
|
|
1494
|
+
await self._session.rollback()
|
|
1495
|
+
if second_attempt:
|
|
1496
|
+
raise AccountIdentityRelockError("Account identity changed during PostgreSQL membership lock acquisition")
|
|
1497
|
+
return await self._lock_postgresql_account_identity_membership(
|
|
1498
|
+
account_id,
|
|
1499
|
+
incoming_chatgpt_account_id,
|
|
1500
|
+
second_attempt=True,
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
async def _lock_postgresql_upsert_identity_candidates(
|
|
1504
|
+
self,
|
|
1505
|
+
account: Account,
|
|
1506
|
+
*,
|
|
1507
|
+
include_email: bool,
|
|
1508
|
+
) -> frozenset[str]:
|
|
1509
|
+
predicates = _upsert_identity_candidate_predicates(account, include_email=include_email)
|
|
1510
|
+
observed = (
|
|
1511
|
+
(await self._session.execute(select(Account.chatgpt_account_id).where(or_(*predicates)))).scalars().all()
|
|
1512
|
+
)
|
|
1513
|
+
identities = frozenset(identity for identity in (*observed, account.chatgpt_account_id) if identity)
|
|
1514
|
+
await lock_postgresql_account_identities(self._session, identities)
|
|
1515
|
+
return identities
|
|
1516
|
+
|
|
1517
|
+
async def _postgresql_upsert_identity_candidates_are_locked(
|
|
1518
|
+
self,
|
|
1519
|
+
account: Account,
|
|
1520
|
+
*,
|
|
1521
|
+
include_email: bool,
|
|
1522
|
+
locked_identities: frozenset[str],
|
|
1523
|
+
) -> bool:
|
|
1524
|
+
predicates = _upsert_identity_candidate_predicates(account, include_email=include_email)
|
|
1525
|
+
current = (
|
|
1526
|
+
(
|
|
1527
|
+
await self._session.execute(
|
|
1528
|
+
select(Account.chatgpt_account_id).where(or_(*predicates)).with_for_update(key_share=True)
|
|
1529
|
+
)
|
|
1530
|
+
)
|
|
1531
|
+
.scalars()
|
|
1532
|
+
.all()
|
|
1533
|
+
)
|
|
1534
|
+
return all(identity is None or identity in locked_identities for identity in current)
|
|
1535
|
+
|
|
1536
|
+
def _dialect_name(self) -> str:
|
|
1537
|
+
return self._session.get_bind().dialect.name
|
|
1538
|
+
|
|
1539
|
+
async def _acquire_sqlite_merge_lock(self) -> None:
|
|
1540
|
+
try:
|
|
1541
|
+
await self._session.execute(text("BEGIN IMMEDIATE"))
|
|
1542
|
+
except OperationalError as exc:
|
|
1543
|
+
message = str(exc).lower()
|
|
1544
|
+
if "within a transaction" not in message:
|
|
1545
|
+
raise
|
|
1546
|
+
# A no-op write escalates the current deferred transaction to a write
|
|
1547
|
+
# transaction, serializing concurrent writers.
|
|
1548
|
+
await self._session.execute(text("UPDATE accounts SET id = id WHERE 1 = 0"))
|
|
1549
|
+
|
|
1550
|
+
async def _acquire_postgresql_merge_lock(self, email: str) -> None:
|
|
1551
|
+
lock_key = advisory_lock_key("merge-email", email)
|
|
1552
|
+
await self._session.execute(
|
|
1553
|
+
text("SELECT pg_advisory_xact_lock(:lock_key)"),
|
|
1554
|
+
{"lock_key": lock_key},
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
async def _acquire_postgresql_identity_lock(self, account_id: str) -> None:
|
|
1558
|
+
lock_key = advisory_lock_key("account-id", account_id)
|
|
1559
|
+
await self._session.execute(
|
|
1560
|
+
text("SELECT pg_advisory_xact_lock(:lock_key)"),
|
|
1561
|
+
{"lock_key": lock_key},
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _apply_account_updates(target: Account, source: Account) -> None:
|
|
1566
|
+
if source.chatgpt_account_id is not None:
|
|
1567
|
+
target.chatgpt_account_id = source.chatgpt_account_id
|
|
1568
|
+
if source.chatgpt_user_id is not None:
|
|
1569
|
+
target.chatgpt_user_id = source.chatgpt_user_id
|
|
1570
|
+
target.email = source.email
|
|
1571
|
+
if source.workspace_id is not None or target.workspace_id is None:
|
|
1572
|
+
target.workspace_id = source.workspace_id
|
|
1573
|
+
target.workspace_label = source.workspace_label
|
|
1574
|
+
target.seat_type = source.seat_type
|
|
1575
|
+
if not target.codex_installation_id:
|
|
1576
|
+
target.codex_installation_id = source.codex_installation_id or str(uuid.uuid4())
|
|
1577
|
+
target.plan_type = source.plan_type
|
|
1578
|
+
target.access_token_encrypted = source.access_token_encrypted
|
|
1579
|
+
target.refresh_token_encrypted = source.refresh_token_encrypted
|
|
1580
|
+
target.id_token_encrypted = source.id_token_encrypted
|
|
1581
|
+
target.last_refresh = source.last_refresh
|
|
1582
|
+
target.status = source.status
|
|
1583
|
+
target.deactivation_reason = source.deactivation_reason
|
|
1584
|
+
target.reset_at = source.reset_at
|
|
1585
|
+
target.blocked_at = source.blocked_at
|
|
1586
|
+
# A credential replacement (re-import/reauth) supersedes a pending
|
|
1587
|
+
# background deletion: clearing the marker makes the deletion worker
|
|
1588
|
+
# abandon the account before finalizing (rows already drained stay
|
|
1589
|
+
# detached — history loss was requested by the earlier delete).
|
|
1590
|
+
target.delete_requested_at = None
|
|
1591
|
+
target.delete_history_requested = False
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _slot_lock_key(account: Account, *, preserve_unknown_workspace_duplicates: bool = True) -> str:
|
|
1595
|
+
return _slot_lock_keys(
|
|
1596
|
+
account,
|
|
1597
|
+
preserve_unknown_workspace_duplicates=preserve_unknown_workspace_duplicates,
|
|
1598
|
+
)[0]
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _slot_lock_keys(account: Account, *, preserve_unknown_workspace_duplicates: bool = True) -> tuple[str, ...]:
|
|
1602
|
+
keys: list[str] = []
|
|
1603
|
+
workspace_key = _workspace_slot_key(account)
|
|
1604
|
+
if account.chatgpt_account_id:
|
|
1605
|
+
if workspace_key:
|
|
1606
|
+
keys.append(f"slot:{account.chatgpt_account_id}:{workspace_key}")
|
|
1607
|
+
elif account.email:
|
|
1608
|
+
keys.append(f"slot:{account.chatgpt_account_id}:{account.email}")
|
|
1609
|
+
if account.email and workspace_key:
|
|
1610
|
+
keys.append(f"slot-email:{account.email}:{workspace_key}")
|
|
1611
|
+
if not preserve_unknown_workspace_duplicates:
|
|
1612
|
+
keys.append(f"slot-email-unknown:{account.email}")
|
|
1613
|
+
if keys:
|
|
1614
|
+
return tuple(keys)
|
|
1615
|
+
if account.email and not preserve_unknown_workspace_duplicates:
|
|
1616
|
+
return (f"slot-email-unknown:{account.email}",)
|
|
1617
|
+
return (f"slot-local:{account.id}",)
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def _upsert_identity_candidate_predicates(account: Account, *, include_email: bool) -> list[Any]:
|
|
1621
|
+
predicates = [Account.id == account.id]
|
|
1622
|
+
if account.chatgpt_account_id:
|
|
1623
|
+
predicates.append(Account.chatgpt_account_id == account.chatgpt_account_id)
|
|
1624
|
+
if include_email and account.email:
|
|
1625
|
+
predicates.append(Account.email == account.email)
|
|
1626
|
+
return predicates
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _same_unknown_workspace_identity(existing: Account, incoming: Account) -> bool:
|
|
1630
|
+
return (
|
|
1631
|
+
_workspace_slot_key(existing) is None
|
|
1632
|
+
and _workspace_slot_key(incoming) is None
|
|
1633
|
+
and existing.chatgpt_account_id == incoming.chatgpt_account_id
|
|
1634
|
+
and existing.email == incoming.email
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _workspace_slot_identity(account: Account) -> tuple[Any, str] | None:
|
|
1639
|
+
if account.workspace_id:
|
|
1640
|
+
return Account.workspace_id, account.workspace_id
|
|
1641
|
+
if account.workspace_label:
|
|
1642
|
+
return Account.workspace_label, account.workspace_label
|
|
1643
|
+
return None
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _workspace_slot_key(account: Account) -> str | None:
|
|
1647
|
+
if account.workspace_id:
|
|
1648
|
+
return account.workspace_id
|
|
1649
|
+
if account.workspace_label:
|
|
1650
|
+
return account.workspace_label
|
|
1651
|
+
return None
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _is_workspace_less_reauth_for_known_slot(
|
|
1655
|
+
existing: Account,
|
|
1656
|
+
incoming: Account,
|
|
1657
|
+
*,
|
|
1658
|
+
merge_by_chatgpt_identity: bool,
|
|
1659
|
+
) -> bool:
|
|
1660
|
+
return (
|
|
1661
|
+
merge_by_chatgpt_identity
|
|
1662
|
+
and existing.workspace_id is not None
|
|
1663
|
+
and incoming.workspace_id is None
|
|
1664
|
+
and incoming.chatgpt_account_id is not None
|
|
1665
|
+
and existing.chatgpt_account_id == incoming.chatgpt_account_id
|
|
1666
|
+
and existing.email == incoming.email
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _can_reuse_email_fallback(existing: Account, incoming: Account) -> bool:
|
|
1671
|
+
existing_workspace_key = _workspace_slot_key(existing)
|
|
1672
|
+
incoming_workspace_key = _workspace_slot_key(incoming)
|
|
1673
|
+
if existing_workspace_key and incoming_workspace_key and existing_workspace_key != incoming_workspace_key:
|
|
1674
|
+
return False
|
|
1675
|
+
return (
|
|
1676
|
+
not incoming.chatgpt_account_id
|
|
1677
|
+
or not existing.chatgpt_account_id
|
|
1678
|
+
or existing.chatgpt_account_id == incoming.chatgpt_account_id
|
|
1679
|
+
)
|