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,1196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import html
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import AbstractAsyncContextManager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from aiohttp import web
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.exc import OperationalError
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from app.core.auth import (
|
|
19
|
+
DEFAULT_EMAIL,
|
|
20
|
+
DEFAULT_PLAN,
|
|
21
|
+
OpenAIAuthClaims,
|
|
22
|
+
clean_account_identity_part,
|
|
23
|
+
extract_id_token_claims,
|
|
24
|
+
generate_unique_account_id,
|
|
25
|
+
normalize_seat_type,
|
|
26
|
+
resolve_seat_identity,
|
|
27
|
+
)
|
|
28
|
+
from app.core.auth.api_key_cache import get_api_key_cache
|
|
29
|
+
from app.core.cache.invalidation import NAMESPACE_API_KEY, get_cache_invalidation_poller
|
|
30
|
+
from app.core.clients.oauth import (
|
|
31
|
+
OAuthError,
|
|
32
|
+
OAuthTokens,
|
|
33
|
+
build_authorization_url,
|
|
34
|
+
exchange_authorization_code,
|
|
35
|
+
exchange_device_token,
|
|
36
|
+
generate_pkce_pair,
|
|
37
|
+
request_device_code,
|
|
38
|
+
)
|
|
39
|
+
from app.core.config.settings import OAUTH_CALLBACK_PORT, OAUTH_REDIRECT_URI, get_settings
|
|
40
|
+
from app.core.crypto import TokenEncryptor
|
|
41
|
+
from app.core.plan_types import coerce_account_plan_type
|
|
42
|
+
from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route
|
|
43
|
+
from app.core.utils.time import naive_utc_to_epoch, utcnow
|
|
44
|
+
from app.db.models import Account, AccountProxyBinding, AccountStatus
|
|
45
|
+
from app.db.session import get_background_session, sqlite_writer_section
|
|
46
|
+
from app.modules.accounts.repository import AccountIdentityConflictError, AccountsRepository
|
|
47
|
+
from app.modules.oauth.repository import (
|
|
48
|
+
OAuthFlowRecord,
|
|
49
|
+
OAuthFlowRepository,
|
|
50
|
+
epoch_to_naive_utc,
|
|
51
|
+
)
|
|
52
|
+
from app.modules.oauth.schemas import (
|
|
53
|
+
ManualCallbackResponse,
|
|
54
|
+
OauthCompleteRequest,
|
|
55
|
+
OauthCompleteResponse,
|
|
56
|
+
OauthStartRequest,
|
|
57
|
+
OauthStartResponse,
|
|
58
|
+
OauthStatusResponse,
|
|
59
|
+
)
|
|
60
|
+
from app.modules.proxy.account_cache import (
|
|
61
|
+
clear_account_routing_unavailable,
|
|
62
|
+
get_account_selection_cache,
|
|
63
|
+
propagate_account_routing_change,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_async_sleep = asyncio.sleep
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
_SUCCESS_TEMPLATE = Path(__file__).resolve().parent / "templates" / "oauth_success.html"
|
|
69
|
+
_TERMINAL_OAUTH_STATUSES = {"error", "success"}
|
|
70
|
+
_MAX_RETAINED_TERMINAL_OAUTH_FLOWS = 16
|
|
71
|
+
_PENDING_BROWSER_OAUTH_FLOW_TTL_SECONDS = 15 * 60
|
|
72
|
+
_ACCOUNT_IDENTITY_CONFLICT_MESSAGE = (
|
|
73
|
+
"Multiple accounts match the authenticated identity. Remove duplicate accounts and retry OAuth."
|
|
74
|
+
)
|
|
75
|
+
_REAUTH_SEAT_MISMATCH_MESSAGE = (
|
|
76
|
+
"The account you signed in as is not the one being re-authenticated. "
|
|
77
|
+
"No changes were made. Sign out of ChatGPT (or use a private window), then re-run "
|
|
78
|
+
"reauthentication and log in as the exact account that needs repair."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _has_active_proxy_bindings(session: AsyncSession) -> bool:
|
|
83
|
+
try:
|
|
84
|
+
result = await session.execute(
|
|
85
|
+
select(AccountProxyBinding.id).where(AccountProxyBinding.is_active.is_(True)).limit(1)
|
|
86
|
+
)
|
|
87
|
+
return result.scalar_one_or_none() is not None
|
|
88
|
+
except OperationalError:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _oauth_route() -> ResolvedUpstreamRoute | None:
|
|
93
|
+
async with get_background_session() as session:
|
|
94
|
+
strict = await _has_active_proxy_bindings(session)
|
|
95
|
+
try:
|
|
96
|
+
return await resolve_upstream_route(
|
|
97
|
+
session,
|
|
98
|
+
account_id=None,
|
|
99
|
+
operation="oauth",
|
|
100
|
+
scope="bootstrap",
|
|
101
|
+
# strict=True forces default-pool requirement; None defers to dashboard setting
|
|
102
|
+
strict=strict or None,
|
|
103
|
+
)
|
|
104
|
+
except UpstreamProxyRouteError as exc:
|
|
105
|
+
raise OAuthError(exc.reason, str(exc), status_code=502) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ReauthSeatMismatchError(Exception):
|
|
109
|
+
"""Raised when a targeted reauth callback returns a different seat than intended."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, intended_email: str | None, returned_email: str | None) -> None:
|
|
112
|
+
self.intended_email = intended_email
|
|
113
|
+
self.returned_email = returned_email
|
|
114
|
+
super().__init__(
|
|
115
|
+
"Reauthentication returned a different account than the one being repaired "
|
|
116
|
+
f"(expected {intended_email or 'the selected seat'}, got {returned_email or 'unknown'})."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class OAuthState:
|
|
122
|
+
flow_id: str | None = None
|
|
123
|
+
status: str = "pending"
|
|
124
|
+
method: str | None = None
|
|
125
|
+
error_message: str | None = None
|
|
126
|
+
state_token: str | None = None
|
|
127
|
+
intended_account_id: str | None = None
|
|
128
|
+
code_verifier: str | None = None
|
|
129
|
+
device_auth_id: str | None = None
|
|
130
|
+
user_code: str | None = None
|
|
131
|
+
interval_seconds: int | None = None
|
|
132
|
+
expires_at: float | None = None
|
|
133
|
+
finished_at: float | None = None
|
|
134
|
+
callback_server: "OAuthCallbackServer | None" = None
|
|
135
|
+
poll_task: asyncio.Task[None] | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class OAuthStateStore:
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
self._lock = asyncio.Lock()
|
|
141
|
+
self._state = OAuthState(status="idle")
|
|
142
|
+
self._flows: dict[str, OAuthState] = {}
|
|
143
|
+
self._state_token_index: dict[str, str] = {}
|
|
144
|
+
self._callback_server: OAuthCallbackServer | None = None
|
|
145
|
+
self._callback_server_stop_task: asyncio.Task[None] | None = None
|
|
146
|
+
self._browser_flow_expiry_task: asyncio.Task[None] | None = None
|
|
147
|
+
self._browser_flow_expiry_changed = asyncio.Event()
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def lock(self) -> asyncio.Lock:
|
|
151
|
+
return self._lock
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def state(self) -> OAuthState:
|
|
155
|
+
return self._state
|
|
156
|
+
|
|
157
|
+
def get_flow_locked(self, flow_id: str | None) -> OAuthState | None:
|
|
158
|
+
resolved_flow_id = flow_id or self._state.flow_id
|
|
159
|
+
if resolved_flow_id is None:
|
|
160
|
+
return None
|
|
161
|
+
return self._flows.get(resolved_flow_id)
|
|
162
|
+
|
|
163
|
+
def get_flow_by_state_token_locked(self, state_token: str | None) -> OAuthState | None:
|
|
164
|
+
if state_token is None:
|
|
165
|
+
return None
|
|
166
|
+
flow_id = self._state_token_index.get(state_token)
|
|
167
|
+
if flow_id is None:
|
|
168
|
+
return None
|
|
169
|
+
return self._flows.get(flow_id)
|
|
170
|
+
|
|
171
|
+
def remember_flow_locked(self, flow: OAuthState) -> None:
|
|
172
|
+
if flow.flow_id is None:
|
|
173
|
+
raise ValueError("flow_id is required")
|
|
174
|
+
self.prune_expired_pending_browser_flows_locked()
|
|
175
|
+
self._flows[flow.flow_id] = flow
|
|
176
|
+
if flow.state_token is not None:
|
|
177
|
+
self._state_token_index[flow.state_token] = flow.flow_id
|
|
178
|
+
self.set_latest_flow_locked(flow)
|
|
179
|
+
|
|
180
|
+
def set_latest_flow_locked(self, flow: OAuthState) -> None:
|
|
181
|
+
self._state = OAuthState(
|
|
182
|
+
flow_id=flow.flow_id,
|
|
183
|
+
status=flow.status,
|
|
184
|
+
method=flow.method,
|
|
185
|
+
error_message=flow.error_message,
|
|
186
|
+
state_token=flow.state_token,
|
|
187
|
+
code_verifier=flow.code_verifier,
|
|
188
|
+
device_auth_id=flow.device_auth_id,
|
|
189
|
+
user_code=flow.user_code,
|
|
190
|
+
interval_seconds=flow.interval_seconds,
|
|
191
|
+
expires_at=flow.expires_at,
|
|
192
|
+
finished_at=flow.finished_at,
|
|
193
|
+
poll_task=flow.poll_task,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def set_flow_status_locked(self, flow: OAuthState, *, status: str, error_message: str | None) -> None:
|
|
197
|
+
flow.status = status
|
|
198
|
+
flow.error_message = error_message
|
|
199
|
+
flow.finished_at = time.time() if status in _TERMINAL_OAUTH_STATUSES else None
|
|
200
|
+
self.set_latest_flow_locked(flow)
|
|
201
|
+
if status in _TERMINAL_OAUTH_STATUSES:
|
|
202
|
+
self.prune_terminal_flows_locked()
|
|
203
|
+
|
|
204
|
+
def has_pending_browser_flows_locked(self) -> bool:
|
|
205
|
+
self.prune_expired_pending_browser_flows_locked()
|
|
206
|
+
return any(flow.method == "browser" and flow.status == "pending" for flow in self._flows.values())
|
|
207
|
+
|
|
208
|
+
def next_pending_browser_flow_expiry_locked(self) -> float | None:
|
|
209
|
+
return min(
|
|
210
|
+
(
|
|
211
|
+
flow.expires_at
|
|
212
|
+
for flow in self._flows.values()
|
|
213
|
+
if flow.method == "browser" and flow.status == "pending" and flow.expires_at is not None
|
|
214
|
+
),
|
|
215
|
+
default=None,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def reset(self) -> None:
|
|
219
|
+
server: OAuthCallbackServer | None = None
|
|
220
|
+
async with self._lock:
|
|
221
|
+
expiry_task = self._browser_flow_expiry_task
|
|
222
|
+
self._browser_flow_expiry_task = None
|
|
223
|
+
if expiry_task is not None:
|
|
224
|
+
expiry_task.cancel()
|
|
225
|
+
server = self._cleanup_locked()
|
|
226
|
+
self._state = OAuthState(status="idle")
|
|
227
|
+
if expiry_task is not None:
|
|
228
|
+
await asyncio.gather(expiry_task, return_exceptions=True)
|
|
229
|
+
if server is not None:
|
|
230
|
+
await server.stop()
|
|
231
|
+
|
|
232
|
+
def _cleanup_locked(self, *, clear_callback_server: bool = True) -> OAuthCallbackServer | None:
|
|
233
|
+
for flow in self._flows.values():
|
|
234
|
+
task = flow.poll_task
|
|
235
|
+
if task and not task.done():
|
|
236
|
+
task.cancel()
|
|
237
|
+
server = self._callback_server
|
|
238
|
+
if clear_callback_server:
|
|
239
|
+
self._callback_server = None
|
|
240
|
+
self._flows.clear()
|
|
241
|
+
self._state_token_index.clear()
|
|
242
|
+
self._browser_flow_expiry_changed.set()
|
|
243
|
+
return server
|
|
244
|
+
|
|
245
|
+
def prune_terminal_flows_locked(self) -> None:
|
|
246
|
+
terminal_flows = [
|
|
247
|
+
flow
|
|
248
|
+
for flow in self._flows.values()
|
|
249
|
+
if flow.flow_id is not None and flow.status in _TERMINAL_OAUTH_STATUSES
|
|
250
|
+
]
|
|
251
|
+
extra_count = len(terminal_flows) - _MAX_RETAINED_TERMINAL_OAUTH_FLOWS
|
|
252
|
+
if extra_count <= 0:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
terminal_flows.sort(key=lambda flow: flow.finished_at or 0)
|
|
256
|
+
for flow in terminal_flows[:extra_count]:
|
|
257
|
+
self.remove_flow_locked(flow)
|
|
258
|
+
|
|
259
|
+
def prune_expired_pending_browser_flows_locked(self) -> None:
|
|
260
|
+
now = time.time()
|
|
261
|
+
expired_flows = [
|
|
262
|
+
flow
|
|
263
|
+
for flow in self._flows.values()
|
|
264
|
+
if flow.method == "browser"
|
|
265
|
+
and flow.status == "pending"
|
|
266
|
+
and flow.expires_at is not None
|
|
267
|
+
and flow.expires_at <= now
|
|
268
|
+
]
|
|
269
|
+
for flow in expired_flows:
|
|
270
|
+
self.remove_flow_locked(flow)
|
|
271
|
+
|
|
272
|
+
def remove_pending_device_flows_locked(self) -> None:
|
|
273
|
+
pending_device_flows = [
|
|
274
|
+
flow for flow in self._flows.values() if flow.method == "device" and flow.status == "pending"
|
|
275
|
+
]
|
|
276
|
+
for flow in pending_device_flows:
|
|
277
|
+
task = flow.poll_task
|
|
278
|
+
if task and not task.done():
|
|
279
|
+
task.cancel()
|
|
280
|
+
self.remove_flow_locked(flow)
|
|
281
|
+
|
|
282
|
+
def remove_flow_locked(self, flow: OAuthState) -> None:
|
|
283
|
+
removed_latest = flow.flow_id is not None and flow.flow_id == self._state.flow_id
|
|
284
|
+
if flow.flow_id is not None:
|
|
285
|
+
self._flows.pop(flow.flow_id, None)
|
|
286
|
+
if flow.state_token is not None:
|
|
287
|
+
self._state_token_index.pop(flow.state_token, None)
|
|
288
|
+
if removed_latest:
|
|
289
|
+
self._restore_latest_flow_locked()
|
|
290
|
+
|
|
291
|
+
def _restore_latest_flow_locked(self) -> None:
|
|
292
|
+
if not self._flows:
|
|
293
|
+
self._state = OAuthState(status="idle")
|
|
294
|
+
return
|
|
295
|
+
latest_flow = max(
|
|
296
|
+
self._flows.values(),
|
|
297
|
+
key=lambda flow: flow.finished_at or flow.expires_at or 0,
|
|
298
|
+
)
|
|
299
|
+
self.set_latest_flow_locked(latest_flow)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class OAuthCallbackServer:
|
|
303
|
+
def __init__(
|
|
304
|
+
self,
|
|
305
|
+
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
|
306
|
+
host: str = "127.0.0.1",
|
|
307
|
+
port: int = 1455,
|
|
308
|
+
) -> None:
|
|
309
|
+
self._handler = handler
|
|
310
|
+
self._host = host
|
|
311
|
+
self._port = port
|
|
312
|
+
self._runner: web.AppRunner | None = None
|
|
313
|
+
self._site: web.TCPSite | None = None
|
|
314
|
+
|
|
315
|
+
async def start(self) -> None:
|
|
316
|
+
app = web.Application()
|
|
317
|
+
app.router.add_get("/auth/callback", self._handler)
|
|
318
|
+
self._runner = web.AppRunner(app, access_log=None)
|
|
319
|
+
await self._runner.setup()
|
|
320
|
+
self._site = web.TCPSite(self._runner, self._host, self._port)
|
|
321
|
+
await self._site.start()
|
|
322
|
+
|
|
323
|
+
async def stop(self) -> None:
|
|
324
|
+
if self._runner:
|
|
325
|
+
await self._runner.cleanup()
|
|
326
|
+
self._runner = None
|
|
327
|
+
self._site = None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
_OAUTH_STORE = OAuthStateStore()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class OauthService:
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
accounts_repo: AccountsRepository,
|
|
337
|
+
repo_factory: Callable[[], AbstractAsyncContextManager[AccountsRepository]] | None = None,
|
|
338
|
+
store: OAuthStateStore | None = None,
|
|
339
|
+
) -> None:
|
|
340
|
+
self._accounts_repo = accounts_repo
|
|
341
|
+
self._encryptor = TokenEncryptor()
|
|
342
|
+
# ``store`` is the process-local runtime registry (callback server +
|
|
343
|
+
# device poll tasks). It defaults to the module singleton; tests inject
|
|
344
|
+
# a distinct store to simulate a second replica over the shared DB.
|
|
345
|
+
self._store = store if store is not None else _OAUTH_STORE
|
|
346
|
+
self._repo_factory = repo_factory
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------
|
|
349
|
+
# Shared DB-backed flow persistence (cross-replica source of truth)
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
async def _persist_flow_record(self, record: OAuthFlowRecord) -> None:
|
|
353
|
+
async with get_background_session() as session:
|
|
354
|
+
repo = OAuthFlowRepository(session, self._encryptor)
|
|
355
|
+
await repo.purge_expired(terminal_keep=_MAX_RETAINED_TERMINAL_OAUTH_FLOWS)
|
|
356
|
+
await repo.create(record)
|
|
357
|
+
|
|
358
|
+
async def _claim_device_slot(self, flow_id: str) -> None:
|
|
359
|
+
"""Atomically make ``flow_id`` the single current device flow, superseding
|
|
360
|
+
any prior one. Serialized in-process for SQLite; the single UPSERT is
|
|
361
|
+
atomic across replicas/processes on both backends."""
|
|
362
|
+
|
|
363
|
+
async with sqlite_writer_section():
|
|
364
|
+
async with get_background_session() as session:
|
|
365
|
+
await OAuthFlowRepository(session, self._encryptor).claim_device_slot(flow_id)
|
|
366
|
+
|
|
367
|
+
async def _consume_device_slot(self, flow_id: str | None) -> bool:
|
|
368
|
+
"""Atomically consume the device slot iff ``flow_id`` still holds it.
|
|
369
|
+
|
|
370
|
+
The poller's point of no return before persisting tokens: returns False
|
|
371
|
+
(and the poller aborts without persisting) when the flow was superseded
|
|
372
|
+
by a newer device start, which atomically UPSERTed the slot to a
|
|
373
|
+
different ``flow_id``."""
|
|
374
|
+
|
|
375
|
+
if flow_id is None:
|
|
376
|
+
return False
|
|
377
|
+
async with sqlite_writer_section():
|
|
378
|
+
async with get_background_session() as session:
|
|
379
|
+
return await OAuthFlowRepository(session, self._encryptor).consume_device_slot(flow_id)
|
|
380
|
+
|
|
381
|
+
async def _persist_flow_status(self, flow_id: str, *, status: str, error_message: str | None) -> bool:
|
|
382
|
+
"""Write a durable status transition. Returns whether it was applied; a
|
|
383
|
+
non-success write is rejected (``False``) by the monotonic guard when the
|
|
384
|
+
durable row is already ``success`` (a racing winner committed first)."""
|
|
385
|
+
|
|
386
|
+
async with get_background_session() as session:
|
|
387
|
+
repo = OAuthFlowRepository(session, self._encryptor)
|
|
388
|
+
return await repo.set_status(flow_id, status=status, error_message=error_message)
|
|
389
|
+
|
|
390
|
+
async def _load_flow_record(
|
|
391
|
+
self,
|
|
392
|
+
*,
|
|
393
|
+
flow_id: str | None = None,
|
|
394
|
+
state_token: str | None = None,
|
|
395
|
+
) -> OAuthFlowRecord | None:
|
|
396
|
+
async with get_background_session() as session:
|
|
397
|
+
repo = OAuthFlowRepository(session, self._encryptor)
|
|
398
|
+
if flow_id is not None:
|
|
399
|
+
return await repo.get_by_flow_id(flow_id)
|
|
400
|
+
if state_token is not None:
|
|
401
|
+
return await repo.get_by_state_token(state_token)
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _record_to_state(record: OAuthFlowRecord) -> OAuthState:
|
|
406
|
+
return OAuthState(
|
|
407
|
+
flow_id=record.flow_id,
|
|
408
|
+
status=record.status,
|
|
409
|
+
method=record.method,
|
|
410
|
+
error_message=record.error_message,
|
|
411
|
+
state_token=record.state_token,
|
|
412
|
+
intended_account_id=record.intended_account_id,
|
|
413
|
+
code_verifier=record.code_verifier,
|
|
414
|
+
device_auth_id=record.device_auth_id,
|
|
415
|
+
user_code=record.user_code,
|
|
416
|
+
interval_seconds=record.interval_seconds,
|
|
417
|
+
expires_at=naive_utc_to_epoch(record.expires_at) if record.expires_at is not None else None,
|
|
418
|
+
finished_at=naive_utc_to_epoch(record.finished_at) if record.finished_at is not None else None,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def _reconcile_flow_from_durable(
|
|
422
|
+
self,
|
|
423
|
+
*,
|
|
424
|
+
flow_id: str | None = None,
|
|
425
|
+
state_token: str | None = None,
|
|
426
|
+
) -> OAuthFlowRecord | None:
|
|
427
|
+
"""Single authoritative reconciliation gate: make the local in-memory
|
|
428
|
+
``OAuthState`` agree with the shared DB BEFORE any local-state-based
|
|
429
|
+
decision, and return the durable record.
|
|
430
|
+
|
|
431
|
+
The shared DB always wins over a local ``pending``. This one gate closes
|
|
432
|
+
the whole class of "origin replica acts on stale local state" bugs by
|
|
433
|
+
unifying hydrate / terminal-reconcile / expiry-prune. Every entry point
|
|
434
|
+
that resolves a flow from local state MUST call this first:
|
|
435
|
+
|
|
436
|
+
- durable terminal (``success``/``error``) OVERRIDES a local ``pending``
|
|
437
|
+
(the flow was completed here or on another replica), so no consumed
|
|
438
|
+
authorization code is replayed and no stale pending is reported;
|
|
439
|
+
- durable ``pending`` present but missing locally -> HYDRATE it (encrypted
|
|
440
|
+
verifier + metadata) so this replica can resume/complete a flow it did
|
|
441
|
+
not start;
|
|
442
|
+
- durable row ABSENT or EXPIRED (``get_by_*`` returns ``None`` for an
|
|
443
|
+
expired pending row) -> DROP any local non-terminal flow so its cached
|
|
444
|
+
verifier / device code can never be reused past the TTL.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
record = await self._load_flow_record(flow_id=flow_id, state_token=state_token)
|
|
448
|
+
async with self._store.lock:
|
|
449
|
+
local = self._store.get_flow_locked(flow_id) if flow_id is not None else None
|
|
450
|
+
if local is None and state_token is not None:
|
|
451
|
+
local = self._store.get_flow_by_state_token_locked(state_token)
|
|
452
|
+
if record is None:
|
|
453
|
+
# No live durable row (absent or expired-pending): a local
|
|
454
|
+
# non-terminal flow is stale and MUST NOT be acted on.
|
|
455
|
+
if local is not None and local.status not in _TERMINAL_OAUTH_STATUSES:
|
|
456
|
+
self._store.remove_flow_locked(local)
|
|
457
|
+
return None
|
|
458
|
+
if record.status in _TERMINAL_OAUTH_STATUSES:
|
|
459
|
+
if local is None:
|
|
460
|
+
self._store.remember_flow_locked(self._record_to_state(record))
|
|
461
|
+
elif local.status != record.status:
|
|
462
|
+
self._store.set_flow_status_locked(local, status=record.status, error_message=record.error_message)
|
|
463
|
+
return record
|
|
464
|
+
# Durable pending: hydrate a flow this replica never saw.
|
|
465
|
+
if local is None:
|
|
466
|
+
self._store.remember_flow_locked(self._record_to_state(record))
|
|
467
|
+
if record.method == "browser":
|
|
468
|
+
self._ensure_browser_flow_expiry_task_locked()
|
|
469
|
+
return record
|
|
470
|
+
|
|
471
|
+
async def start_oauth(self, request: OauthStartRequest) -> OauthStartResponse:
|
|
472
|
+
force_method = (request.force_method or "").lower()
|
|
473
|
+
intended_account_id = clean_account_identity_part(request.account_id)
|
|
474
|
+
if not force_method and not intended_account_id:
|
|
475
|
+
accounts = await self._accounts_repo.list_accounts()
|
|
476
|
+
if accounts:
|
|
477
|
+
server: OAuthCallbackServer | None = None
|
|
478
|
+
stop_task: asyncio.Task[None] | None = None
|
|
479
|
+
async with self._store.lock:
|
|
480
|
+
server = self._store._cleanup_locked(clear_callback_server=False)
|
|
481
|
+
self._store._state = OAuthState(status="success")
|
|
482
|
+
if server is not None:
|
|
483
|
+
stop_task = self._start_callback_server_stop_locked(server)
|
|
484
|
+
if server is not None and stop_task is not None:
|
|
485
|
+
await self._finish_callback_server_stop(server, stop_task)
|
|
486
|
+
return OauthStartResponse(method="browser")
|
|
487
|
+
|
|
488
|
+
if force_method == "device":
|
|
489
|
+
if intended_account_id:
|
|
490
|
+
return await self._start_device_flow(intended_account_id=intended_account_id)
|
|
491
|
+
return await self._start_device_flow()
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
if intended_account_id:
|
|
495
|
+
return await self._start_browser_flow(intended_account_id=intended_account_id)
|
|
496
|
+
return await self._start_browser_flow()
|
|
497
|
+
except OSError:
|
|
498
|
+
if intended_account_id:
|
|
499
|
+
return await self._start_device_flow(intended_account_id=intended_account_id)
|
|
500
|
+
return await self._start_device_flow()
|
|
501
|
+
|
|
502
|
+
async def oauth_status(self, flow_id: str | None = None) -> OauthStatusResponse:
|
|
503
|
+
if flow_id is not None:
|
|
504
|
+
# Durable status is authoritative: the reconciliation gate syncs the
|
|
505
|
+
# local in-memory flow (terminal overrides pending; expired/absent
|
|
506
|
+
# drops the stale local flow) and returns the durable record.
|
|
507
|
+
record = await self._reconcile_flow_from_durable(flow_id=flow_id)
|
|
508
|
+
if record is not None:
|
|
509
|
+
status = record.status if record.status != "idle" else "pending"
|
|
510
|
+
return OauthStatusResponse(status=status, error_message=record.error_message)
|
|
511
|
+
async with self._store.lock:
|
|
512
|
+
state = self._store.get_flow_locked(flow_id)
|
|
513
|
+
if state is None:
|
|
514
|
+
state = self._store.state if flow_id is None else OAuthState(status="pending")
|
|
515
|
+
status = state.status if state.status != "idle" else "pending"
|
|
516
|
+
return OauthStatusResponse(status=status, error_message=state.error_message)
|
|
517
|
+
|
|
518
|
+
async def complete_oauth(self, request: OauthCompleteRequest | None = None) -> OauthCompleteResponse:
|
|
519
|
+
payload = request or OauthCompleteRequest()
|
|
520
|
+
if payload.flow_id is not None:
|
|
521
|
+
# Durable status is authoritative: reconcile local state and report a
|
|
522
|
+
# durable terminal directly (never re-polling a single-use code).
|
|
523
|
+
record = await self._reconcile_flow_from_durable(flow_id=payload.flow_id)
|
|
524
|
+
if record is not None and record.status in _TERMINAL_OAUTH_STATUSES:
|
|
525
|
+
return OauthCompleteResponse(status=record.status)
|
|
526
|
+
async with self._store.lock:
|
|
527
|
+
flow = self._store.get_flow_locked(payload.flow_id)
|
|
528
|
+
state = flow
|
|
529
|
+
if state is None:
|
|
530
|
+
state = self._store.state if payload.flow_id is None else OAuthState(status="pending")
|
|
531
|
+
if payload.device_auth_id and flow is not None:
|
|
532
|
+
flow.device_auth_id = payload.device_auth_id
|
|
533
|
+
if payload.user_code and flow is not None:
|
|
534
|
+
flow.user_code = payload.user_code
|
|
535
|
+
if flow is not None:
|
|
536
|
+
self._store.set_latest_flow_locked(flow)
|
|
537
|
+
if state.method == "device":
|
|
538
|
+
# ``/complete`` only REPORTS device status; it never starts a poll
|
|
539
|
+
# task. The originating replica is the sole device poller (started
|
|
540
|
+
# at ``start``), so a ``/complete`` served on a replica that did
|
|
541
|
+
# not originate the flow reports the durable status (via the gate
|
|
542
|
+
# above) without spawning a duplicate poller for the single-use
|
|
543
|
+
# device code. If the originating replica dies mid-poll the flow
|
|
544
|
+
# simply expires by TTL and the user retries. A targeted query
|
|
545
|
+
# (explicit ``flow_id``, sent by the dashboard after a success
|
|
546
|
+
# status) reports a terminal so the UI can invalidate; the
|
|
547
|
+
# fire-and-forget acknowledgement (no ``flow_id``) returns
|
|
548
|
+
# ``pending`` while the sole poller runs, even if this replica's
|
|
549
|
+
# own poller just raced to a terminal in-memory.
|
|
550
|
+
if state.status in _TERMINAL_OAUTH_STATUSES and payload.flow_id is not None:
|
|
551
|
+
return OauthCompleteResponse(status=state.status)
|
|
552
|
+
return OauthCompleteResponse(status="pending")
|
|
553
|
+
if state.status == "success":
|
|
554
|
+
# Browser / manual-callback: report an observed success.
|
|
555
|
+
return OauthCompleteResponse(status="success")
|
|
556
|
+
return OauthCompleteResponse(status="pending")
|
|
557
|
+
|
|
558
|
+
async def _start_browser_flow(self, *, intended_account_id: str | None = None) -> OauthStartResponse:
|
|
559
|
+
await self._wait_for_callback_server_stop()
|
|
560
|
+
|
|
561
|
+
flow_id = secrets.token_urlsafe(12)
|
|
562
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
563
|
+
state_token = secrets.token_urlsafe(16)
|
|
564
|
+
authorization_url = build_authorization_url(state=state_token, code_challenge=code_challenge)
|
|
565
|
+
settings = get_settings()
|
|
566
|
+
callback_server: OAuthCallbackServer | None = None
|
|
567
|
+
|
|
568
|
+
expires_at = time.time() + _PENDING_BROWSER_OAUTH_FLOW_TTL_SECONDS
|
|
569
|
+
async with self._store.lock:
|
|
570
|
+
self._store.remember_flow_locked(
|
|
571
|
+
OAuthState(
|
|
572
|
+
flow_id=flow_id,
|
|
573
|
+
status="pending",
|
|
574
|
+
method="browser",
|
|
575
|
+
state_token=state_token,
|
|
576
|
+
code_verifier=code_verifier,
|
|
577
|
+
intended_account_id=intended_account_id,
|
|
578
|
+
expires_at=expires_at,
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
if self._store._callback_server is None:
|
|
582
|
+
callback_server = OAuthCallbackServer(
|
|
583
|
+
self._handle_callback,
|
|
584
|
+
host=settings.oauth_callback_host,
|
|
585
|
+
port=OAUTH_CALLBACK_PORT,
|
|
586
|
+
)
|
|
587
|
+
self._store._callback_server = callback_server
|
|
588
|
+
|
|
589
|
+
await self._persist_flow_record(
|
|
590
|
+
OAuthFlowRecord(
|
|
591
|
+
flow_id=flow_id,
|
|
592
|
+
method="browser",
|
|
593
|
+
status="pending",
|
|
594
|
+
state_token=state_token,
|
|
595
|
+
code_verifier=code_verifier,
|
|
596
|
+
intended_account_id=intended_account_id,
|
|
597
|
+
expires_at=epoch_to_naive_utc(expires_at),
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
if callback_server is not None:
|
|
602
|
+
try:
|
|
603
|
+
await callback_server.start()
|
|
604
|
+
except OSError:
|
|
605
|
+
async with self._store.lock:
|
|
606
|
+
if self._store._callback_server is callback_server:
|
|
607
|
+
self._store._callback_server = None
|
|
608
|
+
|
|
609
|
+
async with self._store.lock:
|
|
610
|
+
self._ensure_browser_flow_expiry_task_locked()
|
|
611
|
+
|
|
612
|
+
return OauthStartResponse(
|
|
613
|
+
flow_id=flow_id,
|
|
614
|
+
method="browser",
|
|
615
|
+
authorization_url=authorization_url,
|
|
616
|
+
callback_url=OAUTH_REDIRECT_URI,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
def _ensure_browser_flow_expiry_task_locked(self) -> None:
|
|
620
|
+
"""Wake the store's single timer, including for an earlier hydrated TTL."""
|
|
621
|
+
self._store._browser_flow_expiry_changed.set()
|
|
622
|
+
task = self._store._browser_flow_expiry_task
|
|
623
|
+
if task is None or task.done():
|
|
624
|
+
self._store._browser_flow_expiry_task = asyncio.create_task(self._expire_browser_flows())
|
|
625
|
+
|
|
626
|
+
async def _expire_browser_flows(self) -> None:
|
|
627
|
+
"""Run existing idle-listener cleanup at pending browser deadlines."""
|
|
628
|
+
current = asyncio.current_task()
|
|
629
|
+
changed = self._store._browser_flow_expiry_changed
|
|
630
|
+
try:
|
|
631
|
+
while True:
|
|
632
|
+
async with self._store.lock:
|
|
633
|
+
expires_at = self._store.next_pending_browser_flow_expiry_locked()
|
|
634
|
+
if expires_at is None:
|
|
635
|
+
self._store._browser_flow_expiry_task = None
|
|
636
|
+
return
|
|
637
|
+
changed.clear()
|
|
638
|
+
try:
|
|
639
|
+
await asyncio.wait_for(changed.wait(), timeout=max(0, expires_at - time.time()))
|
|
640
|
+
except TimeoutError:
|
|
641
|
+
await self._stop_callback_server_if_idle()
|
|
642
|
+
except Exception:
|
|
643
|
+
logger.exception("Browser OAuth expiry cleanup failed")
|
|
644
|
+
finally:
|
|
645
|
+
async with self._store.lock:
|
|
646
|
+
if self._store._browser_flow_expiry_task is current:
|
|
647
|
+
self._store._browser_flow_expiry_task = None
|
|
648
|
+
|
|
649
|
+
async def manual_callback(self, callback_url: str, flow_id: str | None = None) -> ManualCallbackResponse:
|
|
650
|
+
"""Process an OAuth callback URL pasted manually by the user.
|
|
651
|
+
|
|
652
|
+
This is useful when the server is accessed remotely and the
|
|
653
|
+
OAuth callback (localhost:1455) is not reachable from the
|
|
654
|
+
user's browser.
|
|
655
|
+
"""
|
|
656
|
+
from urllib.parse import parse_qs, urlparse
|
|
657
|
+
|
|
658
|
+
parsed = urlparse(callback_url)
|
|
659
|
+
params = parse_qs(parsed.query)
|
|
660
|
+
|
|
661
|
+
error = params.get("error", [None])[0]
|
|
662
|
+
code = params.get("code", [None])[0]
|
|
663
|
+
state = params.get("state", [None])[0]
|
|
664
|
+
|
|
665
|
+
if state is not None:
|
|
666
|
+
# Durable status is authoritative: the reconciliation gate hydrates
|
|
667
|
+
# (verifier + metadata) when this replica never saw the flow, and --
|
|
668
|
+
# critically -- overrides a stale local ``pending`` with a durable
|
|
669
|
+
# terminal and drops an expired/absent flow. This closes the callback
|
|
670
|
+
# -replay class: a pasted callback for a flow already completed on
|
|
671
|
+
# another replica never re-exchanges the consumed authorization code.
|
|
672
|
+
await self._reconcile_flow_from_durable(state_token=state)
|
|
673
|
+
|
|
674
|
+
async with self._store.lock:
|
|
675
|
+
flow = self._store.get_flow_by_state_token_locked(state)
|
|
676
|
+
verifier = flow.code_verifier if flow is not None else None
|
|
677
|
+
target_flow_id = flow.flow_id if flow is not None else flow_id
|
|
678
|
+
can_update_error = target_flow_id is not None
|
|
679
|
+
if flow_id is not None and (flow is None or flow.flow_id != flow_id):
|
|
680
|
+
flow = None
|
|
681
|
+
verifier = None
|
|
682
|
+
target_flow_id = None
|
|
683
|
+
can_update_error = False
|
|
684
|
+
# Durable terminal wins: return the recorded outcome instead of
|
|
685
|
+
# replaying the (already-consumed) code.
|
|
686
|
+
terminal_status = flow.status if (flow is not None and flow.status in _TERMINAL_OAUTH_STATUSES) else None
|
|
687
|
+
terminal_error = flow.error_message if flow is not None else None
|
|
688
|
+
|
|
689
|
+
if terminal_status is not None:
|
|
690
|
+
if terminal_status == "success":
|
|
691
|
+
return ManualCallbackResponse(status="success")
|
|
692
|
+
return ManualCallbackResponse(status="error", error_message=terminal_error)
|
|
693
|
+
|
|
694
|
+
if error:
|
|
695
|
+
message = f"OAuth error: {error}"
|
|
696
|
+
if can_update_error and await self._finalize_callback_error(message, flow_id=target_flow_id) == "success":
|
|
697
|
+
return ManualCallbackResponse(status="success")
|
|
698
|
+
return ManualCallbackResponse(status="error", error_message=message)
|
|
699
|
+
|
|
700
|
+
if not code or not state or flow is None or not verifier:
|
|
701
|
+
message = "Invalid OAuth callback: state mismatch or missing code."
|
|
702
|
+
if can_update_error and await self._finalize_callback_error(message, flow_id=target_flow_id) == "success":
|
|
703
|
+
return ManualCallbackResponse(status="success")
|
|
704
|
+
return ManualCallbackResponse(status="error", error_message=message)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
route = await _oauth_route()
|
|
708
|
+
tokens = await exchange_authorization_code(
|
|
709
|
+
code=code,
|
|
710
|
+
code_verifier=verifier,
|
|
711
|
+
route=route,
|
|
712
|
+
allow_direct_egress=route is None,
|
|
713
|
+
)
|
|
714
|
+
await self._persist_tokens(tokens, intended_account_id=flow.intended_account_id)
|
|
715
|
+
await self._set_success(flow.flow_id)
|
|
716
|
+
asyncio.create_task(self._stop_callback_server_if_idle())
|
|
717
|
+
return ManualCallbackResponse(status="success")
|
|
718
|
+
except OAuthError as exc:
|
|
719
|
+
# Loser race: a concurrent callback may have committed success for the
|
|
720
|
+
# same single-use code, so honor the durable success on a rejected
|
|
721
|
+
# error write instead of surfacing this ``invalid_grant``.
|
|
722
|
+
if await self._finalize_callback_error(exc.message, flow_id=flow.flow_id) == "success":
|
|
723
|
+
return ManualCallbackResponse(status="success")
|
|
724
|
+
return ManualCallbackResponse(status="error", error_message=exc.message)
|
|
725
|
+
except ReauthSeatMismatchError:
|
|
726
|
+
if await self._finalize_callback_error(_REAUTH_SEAT_MISMATCH_MESSAGE, flow_id=flow.flow_id) == "success":
|
|
727
|
+
return ManualCallbackResponse(status="success")
|
|
728
|
+
return ManualCallbackResponse(status="error", error_message=_REAUTH_SEAT_MISMATCH_MESSAGE)
|
|
729
|
+
except AccountIdentityConflictError:
|
|
730
|
+
if (
|
|
731
|
+
await self._finalize_callback_error(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE, flow_id=flow.flow_id)
|
|
732
|
+
== "success"
|
|
733
|
+
):
|
|
734
|
+
return ManualCallbackResponse(status="success")
|
|
735
|
+
return ManualCallbackResponse(status="error", error_message=_ACCOUNT_IDENTITY_CONFLICT_MESSAGE)
|
|
736
|
+
except Exception as exc:
|
|
737
|
+
logger.error("manual OAuth callback failed exception_type=%s", type(exc).__name__)
|
|
738
|
+
message = "An internal error occurred."
|
|
739
|
+
if await self._finalize_callback_error(message, flow_id=flow.flow_id) == "success":
|
|
740
|
+
return ManualCallbackResponse(status="success")
|
|
741
|
+
return ManualCallbackResponse(status="error", error_message=message)
|
|
742
|
+
|
|
743
|
+
async def _start_device_flow(self, *, intended_account_id: str | None = None) -> OauthStartResponse:
|
|
744
|
+
flow_id = secrets.token_urlsafe(12)
|
|
745
|
+
try:
|
|
746
|
+
route = await _oauth_route()
|
|
747
|
+
device = await request_device_code(route=route, allow_direct_egress=route is None)
|
|
748
|
+
except OAuthError as exc:
|
|
749
|
+
await self._set_error(exc.message)
|
|
750
|
+
raise
|
|
751
|
+
|
|
752
|
+
expires_at = time.time() + device.expires_in_seconds
|
|
753
|
+
flow = OAuthState(
|
|
754
|
+
flow_id=flow_id,
|
|
755
|
+
status="pending",
|
|
756
|
+
method="device",
|
|
757
|
+
device_auth_id=device.device_auth_id,
|
|
758
|
+
user_code=device.user_code,
|
|
759
|
+
interval_seconds=device.interval_seconds,
|
|
760
|
+
intended_account_id=intended_account_id,
|
|
761
|
+
expires_at=expires_at,
|
|
762
|
+
)
|
|
763
|
+
async with self._store.lock:
|
|
764
|
+
self._store.remove_pending_device_flows_locked()
|
|
765
|
+
self._store.remember_flow_locked(flow)
|
|
766
|
+
|
|
767
|
+
# Persist the durable row BEFORE claiming the single-active slot and
|
|
768
|
+
# starting the sole poll task, so the poller's slot consume never races a
|
|
769
|
+
# not-yet-written row.
|
|
770
|
+
await self._persist_flow_record(
|
|
771
|
+
OAuthFlowRecord(
|
|
772
|
+
flow_id=flow_id,
|
|
773
|
+
method="device",
|
|
774
|
+
status="pending",
|
|
775
|
+
device_auth_id=device.device_auth_id,
|
|
776
|
+
user_code=device.user_code,
|
|
777
|
+
interval_seconds=device.interval_seconds,
|
|
778
|
+
intended_account_id=intended_account_id,
|
|
779
|
+
expires_at=epoch_to_naive_utc(expires_at),
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
async with self._store.lock:
|
|
784
|
+
# A later device start on THIS replica may have superseded this flow
|
|
785
|
+
# while its row was being persisted (the newer start removed it from
|
|
786
|
+
# the local store). Claim the single-active slot and start the sole
|
|
787
|
+
# poll task ONLY if this is still the current local device flow, and
|
|
788
|
+
# do so while holding the store lock: the claim then happens under the
|
|
789
|
+
# lock a competing start must also acquire, so claim order follows
|
|
790
|
+
# supersession order and a superseded start can neither install a
|
|
791
|
+
# stale slot pointer nor start a duplicate poller. A superseded start
|
|
792
|
+
# still returns its device code to its caller but does not poll.
|
|
793
|
+
if self._store.get_flow_locked(flow_id) is flow:
|
|
794
|
+
await self._claim_device_slot(flow_id)
|
|
795
|
+
self._ensure_device_poll_task_locked(flow)
|
|
796
|
+
|
|
797
|
+
return OauthStartResponse(
|
|
798
|
+
flow_id=flow_id,
|
|
799
|
+
method="device",
|
|
800
|
+
verification_url=device.verification_url,
|
|
801
|
+
user_code=device.user_code,
|
|
802
|
+
device_auth_id=device.device_auth_id,
|
|
803
|
+
interval_seconds=device.interval_seconds,
|
|
804
|
+
expires_in_seconds=device.expires_in_seconds,
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
async def _handle_callback(self, request: web.Request) -> web.Response:
|
|
808
|
+
params = request.rel_url.query
|
|
809
|
+
error = params.get("error")
|
|
810
|
+
code = params.get("code")
|
|
811
|
+
state = params.get("state")
|
|
812
|
+
|
|
813
|
+
if state is not None:
|
|
814
|
+
# Durable status is authoritative (see manual_callback): reconcile
|
|
815
|
+
# local state before trusting the cached verifier so a browser
|
|
816
|
+
# redirect that lands back on the origin after the flow completed
|
|
817
|
+
# elsewhere -- or after the TTL -- is not replayed.
|
|
818
|
+
await self._reconcile_flow_from_durable(state_token=state)
|
|
819
|
+
|
|
820
|
+
async with self._store.lock:
|
|
821
|
+
flow = self._store.get_flow_by_state_token_locked(state)
|
|
822
|
+
verifier = flow.code_verifier if flow is not None else None
|
|
823
|
+
terminal_status = flow.status if (flow is not None and flow.status in _TERMINAL_OAUTH_STATUSES) else None
|
|
824
|
+
terminal_error = flow.error_message if flow is not None else None
|
|
825
|
+
|
|
826
|
+
# Durable terminal wins: do not replay a consumed authorization code.
|
|
827
|
+
if terminal_status is not None:
|
|
828
|
+
if terminal_status == "success":
|
|
829
|
+
return self._html_response(_success_html())
|
|
830
|
+
return self._html_response(_error_html(terminal_error or "Authorization failed."))
|
|
831
|
+
|
|
832
|
+
if error:
|
|
833
|
+
outcome = await self._finalize_callback_error(
|
|
834
|
+
f"OAuth error: {error}", flow_id=flow.flow_id if flow is not None else None
|
|
835
|
+
)
|
|
836
|
+
if outcome == "success":
|
|
837
|
+
return self._html_response(_success_html())
|
|
838
|
+
return self._html_response(_error_html("Authorization failed."))
|
|
839
|
+
|
|
840
|
+
if not code or not state or flow is None or not verifier:
|
|
841
|
+
outcome = await self._finalize_callback_error(
|
|
842
|
+
"Invalid OAuth callback state.", flow_id=flow.flow_id if flow is not None else None
|
|
843
|
+
)
|
|
844
|
+
if outcome == "success":
|
|
845
|
+
return self._html_response(_success_html())
|
|
846
|
+
return self._html_response(_error_html("Invalid OAuth callback."))
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
route = await _oauth_route()
|
|
850
|
+
tokens = await exchange_authorization_code(
|
|
851
|
+
code=code,
|
|
852
|
+
code_verifier=verifier,
|
|
853
|
+
route=route,
|
|
854
|
+
allow_direct_egress=route is None,
|
|
855
|
+
)
|
|
856
|
+
await self._persist_tokens(tokens, intended_account_id=flow.intended_account_id)
|
|
857
|
+
await self._set_success(flow.flow_id)
|
|
858
|
+
html = _success_html()
|
|
859
|
+
except OAuthError as exc:
|
|
860
|
+
# Loser race: honor a durable success committed by a concurrent
|
|
861
|
+
# callback for the same single-use code instead of showing an error.
|
|
862
|
+
html = (
|
|
863
|
+
_success_html()
|
|
864
|
+
if await self._finalize_callback_error(exc.message, flow_id=flow.flow_id) == "success"
|
|
865
|
+
else _error_html(exc.message)
|
|
866
|
+
)
|
|
867
|
+
except ReauthSeatMismatchError:
|
|
868
|
+
html = (
|
|
869
|
+
_success_html()
|
|
870
|
+
if await self._finalize_callback_error(_REAUTH_SEAT_MISMATCH_MESSAGE, flow_id=flow.flow_id) == "success"
|
|
871
|
+
else _error_html(_REAUTH_SEAT_MISMATCH_MESSAGE)
|
|
872
|
+
)
|
|
873
|
+
except AccountIdentityConflictError:
|
|
874
|
+
html = (
|
|
875
|
+
_success_html()
|
|
876
|
+
if await self._finalize_callback_error(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE, flow_id=flow.flow_id)
|
|
877
|
+
== "success"
|
|
878
|
+
else _error_html(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE)
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
asyncio.create_task(self._stop_callback_server_if_idle())
|
|
882
|
+
return self._html_response(html)
|
|
883
|
+
|
|
884
|
+
async def _poll_device_tokens(self, flow_id: str | None, context: "DevicePollContext") -> None:
|
|
885
|
+
# Slot ownership is the single authority for who may complete a device
|
|
886
|
+
# flow. Only the poller that atomically consumed the current device slot
|
|
887
|
+
# may persist an account OR write ANY terminal status (success or error).
|
|
888
|
+
# A poller that does not hold/win the slot writes NOTHING: this prevents a
|
|
889
|
+
# losing/duplicate poller that received ``invalid_grant`` for the consumed
|
|
890
|
+
# code from writing ``error`` during the winner's persist window (which
|
|
891
|
+
# would stop the dashboard polling before the winner's success lands).
|
|
892
|
+
consumed = False
|
|
893
|
+
try:
|
|
894
|
+
while time.time() < context.expires_at:
|
|
895
|
+
route = await _oauth_route()
|
|
896
|
+
tokens = await exchange_device_token(
|
|
897
|
+
device_auth_id=context.device_auth_id,
|
|
898
|
+
user_code=context.user_code,
|
|
899
|
+
route=route,
|
|
900
|
+
allow_direct_egress=route is None,
|
|
901
|
+
)
|
|
902
|
+
if tokens:
|
|
903
|
+
# Point of no return: consume the single-active slot. If a
|
|
904
|
+
# newer start superseded this flow, the consume matches zero
|
|
905
|
+
# rows and we abort WITHOUT persisting or writing anything.
|
|
906
|
+
consumed = await self._consume_device_slot(flow_id)
|
|
907
|
+
if not consumed:
|
|
908
|
+
return
|
|
909
|
+
await self._persist_tokens(tokens, intended_account_id=context.intended_account_id)
|
|
910
|
+
await self._set_success(flow_id)
|
|
911
|
+
return
|
|
912
|
+
await _async_sleep(context.interval_seconds)
|
|
913
|
+
# Code expired: only the slot holder may record the terminal error.
|
|
914
|
+
if consumed or await self._consume_device_slot(flow_id):
|
|
915
|
+
await self._set_error("Device code expired.", flow_id=flow_id)
|
|
916
|
+
except OAuthError as exc:
|
|
917
|
+
if consumed or await self._consume_device_slot(flow_id):
|
|
918
|
+
await self._set_error(exc.message, flow_id=flow_id)
|
|
919
|
+
except ReauthSeatMismatchError:
|
|
920
|
+
if consumed or await self._consume_device_slot(flow_id):
|
|
921
|
+
await self._set_error(_REAUTH_SEAT_MISMATCH_MESSAGE, flow_id=flow_id)
|
|
922
|
+
except AccountIdentityConflictError:
|
|
923
|
+
if consumed or await self._consume_device_slot(flow_id):
|
|
924
|
+
await self._set_error(_ACCOUNT_IDENTITY_CONFLICT_MESSAGE, flow_id=flow_id)
|
|
925
|
+
finally:
|
|
926
|
+
async with self._store.lock:
|
|
927
|
+
flow = self._store.get_flow_locked(flow_id)
|
|
928
|
+
current = asyncio.current_task()
|
|
929
|
+
if flow is not None and flow.poll_task is current:
|
|
930
|
+
flow.poll_task = None
|
|
931
|
+
self._store.set_latest_flow_locked(flow)
|
|
932
|
+
|
|
933
|
+
def _ensure_device_poll_task_locked(self, state: OAuthState) -> bool:
|
|
934
|
+
if state.poll_task and not state.poll_task.done():
|
|
935
|
+
return True
|
|
936
|
+
if not state.device_auth_id or not state.user_code or not state.expires_at:
|
|
937
|
+
return False
|
|
938
|
+
|
|
939
|
+
interval = state.interval_seconds if state.interval_seconds is not None else 0
|
|
940
|
+
poll_context = DevicePollContext(
|
|
941
|
+
device_auth_id=state.device_auth_id,
|
|
942
|
+
user_code=state.user_code,
|
|
943
|
+
interval_seconds=max(interval, 0),
|
|
944
|
+
expires_at=state.expires_at,
|
|
945
|
+
intended_account_id=state.intended_account_id,
|
|
946
|
+
)
|
|
947
|
+
state.poll_task = asyncio.create_task(self._poll_device_tokens(state.flow_id, poll_context))
|
|
948
|
+
return True
|
|
949
|
+
|
|
950
|
+
async def _persist_tokens(
|
|
951
|
+
self,
|
|
952
|
+
tokens: OAuthTokens,
|
|
953
|
+
*,
|
|
954
|
+
intended_account_id: str | None = None,
|
|
955
|
+
) -> None:
|
|
956
|
+
claims = extract_id_token_claims(tokens.id_token)
|
|
957
|
+
auth_claims = claims.auth or OpenAIAuthClaims()
|
|
958
|
+
raw_account_id = auth_claims.chatgpt_account_id or claims.chatgpt_account_id
|
|
959
|
+
chatgpt_user_id = resolve_seat_identity(claims, auth_claims)
|
|
960
|
+
email = claims.email or DEFAULT_EMAIL
|
|
961
|
+
workspace_id = clean_account_identity_part(auth_claims.workspace_id or claims.workspace_id)
|
|
962
|
+
workspace_label = clean_account_identity_part(auth_claims.workspace_label or claims.workspace_label)
|
|
963
|
+
seat_type = normalize_seat_type(auth_claims.seat_type or claims.seat_type)
|
|
964
|
+
account_id = generate_unique_account_id(raw_account_id, email, workspace_id, workspace_label)
|
|
965
|
+
plan_type = coerce_account_plan_type(
|
|
966
|
+
auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
|
|
967
|
+
DEFAULT_PLAN,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
account = Account(
|
|
971
|
+
id=intended_account_id or account_id,
|
|
972
|
+
chatgpt_account_id=raw_account_id,
|
|
973
|
+
chatgpt_user_id=chatgpt_user_id,
|
|
974
|
+
email=email,
|
|
975
|
+
workspace_id=workspace_id,
|
|
976
|
+
workspace_label=workspace_label,
|
|
977
|
+
seat_type=seat_type,
|
|
978
|
+
plan_type=plan_type,
|
|
979
|
+
access_token_encrypted=self._encryptor.encrypt(tokens.access_token),
|
|
980
|
+
refresh_token_encrypted=self._encryptor.encrypt(tokens.refresh_token),
|
|
981
|
+
id_token_encrypted=self._encryptor.encrypt(tokens.id_token),
|
|
982
|
+
last_refresh=utcnow(),
|
|
983
|
+
status=AccountStatus.ACTIVE,
|
|
984
|
+
deactivation_reason=None,
|
|
985
|
+
)
|
|
986
|
+
if self._repo_factory:
|
|
987
|
+
async with self._repo_factory() as repo:
|
|
988
|
+
saved = await self._save_oauth_account(repo, account, intended_account_id=intended_account_id)
|
|
989
|
+
saved_id = saved.id
|
|
990
|
+
else:
|
|
991
|
+
saved = await self._save_oauth_account(
|
|
992
|
+
self._accounts_repo,
|
|
993
|
+
account,
|
|
994
|
+
intended_account_id=intended_account_id,
|
|
995
|
+
)
|
|
996
|
+
saved_id = saved.id
|
|
997
|
+
|
|
998
|
+
clear_account_routing_unavailable(saved_id)
|
|
999
|
+
await self._invalidate_account_routing_caches()
|
|
1000
|
+
|
|
1001
|
+
async def _save_oauth_account(
|
|
1002
|
+
self,
|
|
1003
|
+
repo: AccountsRepository,
|
|
1004
|
+
account: Account,
|
|
1005
|
+
*,
|
|
1006
|
+
intended_account_id: str | None,
|
|
1007
|
+
) -> Account:
|
|
1008
|
+
if intended_account_id is None:
|
|
1009
|
+
return await repo.upsert_account_slot(
|
|
1010
|
+
account,
|
|
1011
|
+
preserve_unknown_workspace_duplicates=False,
|
|
1012
|
+
preserve_identity_slots=True,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
intended = await repo.get_by_id(intended_account_id)
|
|
1016
|
+
if intended is None:
|
|
1017
|
+
raise ReauthSeatMismatchError(None, account.email)
|
|
1018
|
+
|
|
1019
|
+
intended_user_id = intended.chatgpt_user_id
|
|
1020
|
+
intended_claims = None
|
|
1021
|
+
if intended_user_id is None:
|
|
1022
|
+
try:
|
|
1023
|
+
intended_token = self._encryptor.decrypt(intended.id_token_encrypted)
|
|
1024
|
+
intended_claims = extract_id_token_claims(intended_token)
|
|
1025
|
+
intended_user_id = resolve_seat_identity(intended_claims, intended_claims.auth)
|
|
1026
|
+
except Exception:
|
|
1027
|
+
intended_user_id = None
|
|
1028
|
+
if intended_claims is None:
|
|
1029
|
+
try:
|
|
1030
|
+
intended_claims = extract_id_token_claims(self._encryptor.decrypt(intended.id_token_encrypted))
|
|
1031
|
+
except Exception:
|
|
1032
|
+
intended_claims = None
|
|
1033
|
+
|
|
1034
|
+
intended_workspace = clean_account_identity_part(intended.workspace_id or intended.workspace_label)
|
|
1035
|
+
callback_workspace = clean_account_identity_part(account.workspace_id or account.workspace_label)
|
|
1036
|
+
callback_claims = None
|
|
1037
|
+
try:
|
|
1038
|
+
callback_claims = extract_id_token_claims(self._encryptor.decrypt(account.id_token_encrypted))
|
|
1039
|
+
except Exception:
|
|
1040
|
+
callback_claims = None
|
|
1041
|
+
|
|
1042
|
+
intended_seat_ids = {
|
|
1043
|
+
clean_account_identity_part(value)
|
|
1044
|
+
for value in (intended_user_id, intended_claims.sub if intended_claims else None)
|
|
1045
|
+
if clean_account_identity_part(value)
|
|
1046
|
+
}
|
|
1047
|
+
callback_seat_ids = {
|
|
1048
|
+
clean_account_identity_part(value)
|
|
1049
|
+
for value in (
|
|
1050
|
+
account.chatgpt_user_id,
|
|
1051
|
+
callback_claims.sub if callback_claims else None,
|
|
1052
|
+
)
|
|
1053
|
+
if clean_account_identity_part(value)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
workspace_matches = (
|
|
1057
|
+
intended.chatgpt_account_id is None or intended.chatgpt_account_id == account.chatgpt_account_id
|
|
1058
|
+
)
|
|
1059
|
+
if workspace_matches and intended.chatgpt_account_id is None and intended_workspace is not None:
|
|
1060
|
+
workspace_matches = bool(callback_workspace is not None and callback_workspace == intended_workspace)
|
|
1061
|
+
seat_matches = bool(intended_seat_ids & callback_seat_ids)
|
|
1062
|
+
if not workspace_matches or not seat_matches:
|
|
1063
|
+
raise ReauthSeatMismatchError(intended.email, account.email)
|
|
1064
|
+
|
|
1065
|
+
saved = await repo.replace_reauthorized(intended_account_id, account)
|
|
1066
|
+
if saved is None:
|
|
1067
|
+
raise ReauthSeatMismatchError(intended.email, account.email)
|
|
1068
|
+
return saved
|
|
1069
|
+
|
|
1070
|
+
async def _invalidate_account_routing_caches(self) -> None:
|
|
1071
|
+
get_account_selection_cache().invalidate()
|
|
1072
|
+
get_api_key_cache().clear()
|
|
1073
|
+
await propagate_account_routing_change()
|
|
1074
|
+
poller = get_cache_invalidation_poller()
|
|
1075
|
+
if poller is not None:
|
|
1076
|
+
await poller.bump(NAMESPACE_API_KEY)
|
|
1077
|
+
|
|
1078
|
+
async def _set_success(self, flow_id: str | None = None) -> None:
|
|
1079
|
+
async with self._store.lock:
|
|
1080
|
+
flow = self._store.get_flow_locked(flow_id)
|
|
1081
|
+
if flow is not None:
|
|
1082
|
+
self._store.set_flow_status_locked(flow, status="success", error_message=None)
|
|
1083
|
+
elif flow_id is None:
|
|
1084
|
+
self._store.state.status = "success"
|
|
1085
|
+
self._store.state.error_message = None
|
|
1086
|
+
if flow_id is not None:
|
|
1087
|
+
# Durable, cross-replica status: the originating replica reads this
|
|
1088
|
+
# on its next status poll instead of its stale in-memory pending.
|
|
1089
|
+
await self._persist_flow_status(flow_id, status="success", error_message=None)
|
|
1090
|
+
|
|
1091
|
+
async def _set_error(self, message: str, flow_id: str | None = None) -> bool:
|
|
1092
|
+
"""Record a terminal error. Returns whether the durable error was applied.
|
|
1093
|
+
|
|
1094
|
+
The durable write happens FIRST: if the monotonic guard rejects it
|
|
1095
|
+
(``False``) because the durable row is already ``success`` — a racing
|
|
1096
|
+
callback/poller committed success for the same single-use code — the
|
|
1097
|
+
local in-memory flow is left untouched (never shadowed into ``error``),
|
|
1098
|
+
and the caller must honor the durable success instead of surfacing the
|
|
1099
|
+
error. ``flow_id`` of ``None`` updates only the local latest state.
|
|
1100
|
+
"""
|
|
1101
|
+
|
|
1102
|
+
if flow_id is None:
|
|
1103
|
+
async with self._store.lock:
|
|
1104
|
+
if self._store.state.flow_id is None:
|
|
1105
|
+
self._store.state.status = "error"
|
|
1106
|
+
self._store.state.error_message = message
|
|
1107
|
+
return True
|
|
1108
|
+
applied = await self._persist_flow_status(flow_id, status="error", error_message=message)
|
|
1109
|
+
if not applied:
|
|
1110
|
+
return False
|
|
1111
|
+
async with self._store.lock:
|
|
1112
|
+
flow = self._store.get_flow_locked(flow_id)
|
|
1113
|
+
if flow is not None:
|
|
1114
|
+
self._store.set_flow_status_locked(flow, status="error", error_message=message)
|
|
1115
|
+
return True
|
|
1116
|
+
|
|
1117
|
+
async def _finalize_callback_error(self, message: str, *, flow_id: str | None) -> str:
|
|
1118
|
+
"""Record a terminal error for a browser/manual-callback flow and return
|
|
1119
|
+
the status to report. If the durable error write is rejected because a
|
|
1120
|
+
racing callback already committed ``success`` for the same single-use
|
|
1121
|
+
code, reconcile the local flow and report the durable ``success`` instead
|
|
1122
|
+
of misreporting an error (the loser must not surface an error)."""
|
|
1123
|
+
|
|
1124
|
+
if await self._set_error(message, flow_id=flow_id):
|
|
1125
|
+
return "error"
|
|
1126
|
+
record = await self._reconcile_flow_from_durable(flow_id=flow_id) if flow_id is not None else None
|
|
1127
|
+
if record is not None and record.status == "success":
|
|
1128
|
+
return "success"
|
|
1129
|
+
return "error"
|
|
1130
|
+
|
|
1131
|
+
def _start_callback_server_stop_locked(self, server: OAuthCallbackServer) -> asyncio.Task[None]:
|
|
1132
|
+
stop_task = self._store._callback_server_stop_task
|
|
1133
|
+
if stop_task is not None and not stop_task.done():
|
|
1134
|
+
return stop_task
|
|
1135
|
+
stop_task = asyncio.create_task(server.stop())
|
|
1136
|
+
self._store._callback_server_stop_task = stop_task
|
|
1137
|
+
return stop_task
|
|
1138
|
+
|
|
1139
|
+
async def _finish_callback_server_stop(
|
|
1140
|
+
self,
|
|
1141
|
+
server: OAuthCallbackServer,
|
|
1142
|
+
stop_task: asyncio.Task[None],
|
|
1143
|
+
) -> None:
|
|
1144
|
+
try:
|
|
1145
|
+
await asyncio.shield(stop_task)
|
|
1146
|
+
finally:
|
|
1147
|
+
async with self._store.lock:
|
|
1148
|
+
if self._store._callback_server is server:
|
|
1149
|
+
self._store._callback_server = None
|
|
1150
|
+
if self._store._callback_server_stop_task is stop_task:
|
|
1151
|
+
self._store._callback_server_stop_task = None
|
|
1152
|
+
|
|
1153
|
+
async def _wait_for_callback_server_stop(self) -> None:
|
|
1154
|
+
while True:
|
|
1155
|
+
async with self._store.lock:
|
|
1156
|
+
stop_task = self._store._callback_server_stop_task
|
|
1157
|
+
if stop_task is None:
|
|
1158
|
+
return
|
|
1159
|
+
await asyncio.shield(stop_task)
|
|
1160
|
+
|
|
1161
|
+
async def _stop_callback_server_if_idle(self) -> None:
|
|
1162
|
+
server: OAuthCallbackServer | None = None
|
|
1163
|
+
stop_task: asyncio.Task[None] | None = None
|
|
1164
|
+
async with self._store.lock:
|
|
1165
|
+
if self._store.has_pending_browser_flows_locked():
|
|
1166
|
+
return
|
|
1167
|
+
server = self._store._callback_server
|
|
1168
|
+
if server:
|
|
1169
|
+
stop_task = self._start_callback_server_stop_locked(server)
|
|
1170
|
+
if server and stop_task:
|
|
1171
|
+
await self._finish_callback_server_stop(server, stop_task)
|
|
1172
|
+
|
|
1173
|
+
@staticmethod
|
|
1174
|
+
def _html_response(html: str) -> web.Response:
|
|
1175
|
+
return web.Response(text=html, content_type="text/html")
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
@dataclass(frozen=True)
|
|
1179
|
+
class DevicePollContext:
|
|
1180
|
+
device_auth_id: str
|
|
1181
|
+
user_code: str
|
|
1182
|
+
interval_seconds: int
|
|
1183
|
+
expires_at: float
|
|
1184
|
+
intended_account_id: str | None = None
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _success_html() -> str:
|
|
1188
|
+
try:
|
|
1189
|
+
return _SUCCESS_TEMPLATE.read_text(encoding="utf-8")
|
|
1190
|
+
except OSError:
|
|
1191
|
+
return "<html><body><h1>Login complete</h1><p>Return to the dashboard.</p></body></html>"
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _error_html(message: str) -> str:
|
|
1195
|
+
escaped_message = html.escape(message, quote=True)
|
|
1196
|
+
return f"<html><body><h1>Login failed</h1><p>{escaped_message}</p></body></html>"
|