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,1488 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Collection, Coroutine, Hashable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Final, Mapping, Protocol, cast
|
|
12
|
+
|
|
13
|
+
from app.core import usage as usage_core
|
|
14
|
+
from app.core.auth.refresh import RefreshError
|
|
15
|
+
from app.core.balancer import (
|
|
16
|
+
PERMANENT_FAILURE_CODES,
|
|
17
|
+
QUOTA_EXCEEDED_COOLDOWN_SECONDS,
|
|
18
|
+
RATE_LIMITED_MIN_COOLDOWN_SECONDS,
|
|
19
|
+
account_status_for_permanent_failure,
|
|
20
|
+
plausible_rate_limit_reset_at,
|
|
21
|
+
)
|
|
22
|
+
from app.core.clients.usage import UsageFetchError, fetch_usage
|
|
23
|
+
from app.core.crypto import TokenEncryptor
|
|
24
|
+
from app.core.plan_types import ACCOUNT_PLAN_TYPES, coerce_account_plan_type, normalize_account_plan_type
|
|
25
|
+
from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route
|
|
26
|
+
from app.core.usage.models import AdditionalRateLimitPayload, UsagePayload, UsageWindow
|
|
27
|
+
from app.core.usage.refresh_policy import USAGE_REFRESH_INTERVAL_SECONDS
|
|
28
|
+
from app.core.utils.request_id import get_request_id
|
|
29
|
+
from app.core.utils.shared_future import wait_on_shared_future
|
|
30
|
+
from app.core.utils.time import utcnow
|
|
31
|
+
from app.db.models import Account, AccountStatus, UsageHistory
|
|
32
|
+
from app.db.session import get_background_session
|
|
33
|
+
from app.modules.accounts.auth_manager import AccountsRepositoryPort, AuthManager, _clean_optional
|
|
34
|
+
from app.modules.accounts.background_repository import BackgroundAccountsRepository
|
|
35
|
+
from app.modules.proxy.account_cache import get_account_selection_cache, mark_account_routing_unavailable
|
|
36
|
+
from app.modules.usage.additional_quota_keys import canonicalize_additional_quota_key
|
|
37
|
+
from app.modules.usage.background_repository import BackgroundAdditionalUsageRepository, BackgroundUsageRepository
|
|
38
|
+
from app.modules.usage.plan_downgrade_observations import (
|
|
39
|
+
InMemoryPlanDowngradeObservationStore,
|
|
40
|
+
PlanDowngradeObservationStorePort,
|
|
41
|
+
credential_fingerprint,
|
|
42
|
+
get_plan_downgrade_observation_store,
|
|
43
|
+
)
|
|
44
|
+
from app.modules.usage.repository import AdditionalUsageRepository, UsageWindowWrite
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UsageRepositoryPort(Protocol):
|
|
50
|
+
async def latest_entry_for_account(
|
|
51
|
+
self,
|
|
52
|
+
account_id: str,
|
|
53
|
+
*,
|
|
54
|
+
window: str | None = None,
|
|
55
|
+
) -> UsageHistory | None: ...
|
|
56
|
+
|
|
57
|
+
async def add_account_snapshot(
|
|
58
|
+
self,
|
|
59
|
+
account_id: str,
|
|
60
|
+
windows: Collection[UsageWindowWrite],
|
|
61
|
+
*,
|
|
62
|
+
recorded_at: datetime | None = None,
|
|
63
|
+
) -> list[UsageHistory]: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AdditionalUsageRepositoryPort(Protocol):
|
|
67
|
+
async def add_entry(
|
|
68
|
+
self,
|
|
69
|
+
account_id: str,
|
|
70
|
+
limit_name: str,
|
|
71
|
+
metered_feature: str,
|
|
72
|
+
window: str,
|
|
73
|
+
used_percent: float,
|
|
74
|
+
reset_at: int | None = None,
|
|
75
|
+
window_minutes: int | None = None,
|
|
76
|
+
recorded_at: datetime | None = None,
|
|
77
|
+
quota_key: str | None = None,
|
|
78
|
+
) -> None: ...
|
|
79
|
+
|
|
80
|
+
async def delete_for_account(self, account_id: str) -> None: ...
|
|
81
|
+
|
|
82
|
+
async def delete_for_account_and_quota_key(self, account_id: str, quota_key: str) -> None: ...
|
|
83
|
+
|
|
84
|
+
async def delete_for_account_and_limit(self, account_id: str, limit_name: str) -> None: ...
|
|
85
|
+
|
|
86
|
+
async def delete_for_account_quota_key_window(
|
|
87
|
+
self,
|
|
88
|
+
account_id: str,
|
|
89
|
+
quota_key: str,
|
|
90
|
+
window: str,
|
|
91
|
+
) -> None: ...
|
|
92
|
+
|
|
93
|
+
async def delete_for_account_limit_window(
|
|
94
|
+
self,
|
|
95
|
+
account_id: str,
|
|
96
|
+
limit_name: str,
|
|
97
|
+
window: str,
|
|
98
|
+
) -> None: ...
|
|
99
|
+
|
|
100
|
+
async def list_quota_keys(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
account_ids: Collection[str] | None = None,
|
|
104
|
+
since: datetime | None = None,
|
|
105
|
+
) -> list[str]: ...
|
|
106
|
+
|
|
107
|
+
async def list_limit_names(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
account_ids: Collection[str] | None = None,
|
|
111
|
+
since: datetime | None = None,
|
|
112
|
+
) -> list[str]: ...
|
|
113
|
+
|
|
114
|
+
async def latest_recorded_at_for_account(self, account_id: str) -> datetime | None: ...
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AccountsRepositoryWithStatusComparePort(AccountsRepositoryPort, Protocol):
|
|
118
|
+
async def update_status_if_current(
|
|
119
|
+
self,
|
|
120
|
+
account_id: str,
|
|
121
|
+
status: AccountStatus,
|
|
122
|
+
deactivation_reason: str | None = None,
|
|
123
|
+
reset_at: int | None = None,
|
|
124
|
+
blocked_at: int | None = None,
|
|
125
|
+
*,
|
|
126
|
+
expected_status: AccountStatus,
|
|
127
|
+
expected_deactivation_reason: str | None = None,
|
|
128
|
+
expected_reset_at: int | None = None,
|
|
129
|
+
expected_blocked_at: int | None = None,
|
|
130
|
+
expected_refresh_token_encrypted: bytes | None = None,
|
|
131
|
+
) -> bool: ...
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class AccountRefreshResult:
|
|
136
|
+
usage_written: bool
|
|
137
|
+
fetch_succeeded: bool = True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class _MergedAdditionalWindow:
|
|
142
|
+
limit_name: str
|
|
143
|
+
metered_feature: str
|
|
144
|
+
used_percent: float
|
|
145
|
+
reset_at: int | None
|
|
146
|
+
window_minutes: int | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Module-level freshness cache for additional-only accounts (no main UsageHistory
|
|
150
|
+
# entry). Used as a fast path to avoid DB queries on every pass within the same
|
|
151
|
+
# process. Updated only after a successful refresh that wrote data.
|
|
152
|
+
_last_successful_refresh: dict[str, datetime] = {}
|
|
153
|
+
_usage_refresh_auth_cooldowns: dict[str, float] = {}
|
|
154
|
+
|
|
155
|
+
# Debounce window for request-triggered refreshes (a streamed
|
|
156
|
+
# ``usage_limit_reached``): one immediate upstream fetch per account per
|
|
157
|
+
# window collapses a 429 storm into a single call. Deliberately a constant
|
|
158
|
+
# rather than a CODEX_LB_* setting (PRINCIPLES.md P2).
|
|
159
|
+
_REQUEST_REFRESH_DEBOUNCE_SECONDS: Final[float] = 15.0
|
|
160
|
+
# Accounts whose usage fetch failed with an ambiguous 401/403 are skipped for
|
|
161
|
+
# this long before the next attempt (fixed; issue #1340 / PRINCIPLES.md P2).
|
|
162
|
+
_USAGE_REFRESH_AUTH_FAILURE_COOLDOWN_SECONDS = 300.0
|
|
163
|
+
_usage_request_refresh_deadlines: dict[str, float] = {}
|
|
164
|
+
|
|
165
|
+
# Fallback for consecutive workspace-less "free" observations (issue #1456) used
|
|
166
|
+
# only when persistence is explicitly disabled -- the DB-less unit-test harness.
|
|
167
|
+
# The process default is the database-backed
|
|
168
|
+
# :class:`PlanDowngradeObservationStore`, so the observation sequence stays
|
|
169
|
+
# coherent across replicas sharing one database; this fallback preserves
|
|
170
|
+
# single-process behavior instead of dropping the guard when no database is
|
|
171
|
+
# available.
|
|
172
|
+
_FALLBACK_PLAN_DOWNGRADE_OBSERVATIONS = InMemoryPlanDowngradeObservationStore()
|
|
173
|
+
|
|
174
|
+
# Number of consecutive agreeing observations required before a workspace-less
|
|
175
|
+
# paid -> free downgrade is persisted. Deliberately a constant rather than a
|
|
176
|
+
# CODEX_LB_* setting: two observations is the minimum that distinguishes a real
|
|
177
|
+
# expiry from a single degraded response, and operators gain nothing from tuning
|
|
178
|
+
# it (PRINCIPLES.md P2, settings-surface ratchet in issue #1340).
|
|
179
|
+
_FREE_PLAN_DOWNGRADE_CONFIRMATIONS: Final[int] = 2
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _UsageRefreshSingleflight:
|
|
183
|
+
def __init__(self) -> None:
|
|
184
|
+
self._inflight: dict[Hashable, asyncio.Task[AccountRefreshResult]] = {}
|
|
185
|
+
self._lock = asyncio.Lock()
|
|
186
|
+
|
|
187
|
+
async def run(
|
|
188
|
+
self,
|
|
189
|
+
account_id: Hashable,
|
|
190
|
+
factory: Callable[[], Awaitable[AccountRefreshResult]],
|
|
191
|
+
*,
|
|
192
|
+
join_existing: bool = True,
|
|
193
|
+
) -> AccountRefreshResult:
|
|
194
|
+
while True:
|
|
195
|
+
wait_for_existing: asyncio.Task[AccountRefreshResult] | None = None
|
|
196
|
+
async with self._lock:
|
|
197
|
+
task = self._inflight.get(account_id)
|
|
198
|
+
if task is None or task.done():
|
|
199
|
+
task = asyncio.create_task(self._run_factory(factory))
|
|
200
|
+
self._inflight[account_id] = task
|
|
201
|
+
task.add_done_callback(
|
|
202
|
+
lambda done, *, key=account_id: self._clear_if_current(key, done),
|
|
203
|
+
)
|
|
204
|
+
break
|
|
205
|
+
if join_existing:
|
|
206
|
+
break
|
|
207
|
+
wait_for_existing = task
|
|
208
|
+
if wait_for_existing is None:
|
|
209
|
+
break
|
|
210
|
+
try:
|
|
211
|
+
await wait_on_shared_future(wait_for_existing)
|
|
212
|
+
except asyncio.CancelledError:
|
|
213
|
+
current_task = asyncio.current_task()
|
|
214
|
+
if current_task is not None and current_task.cancelling():
|
|
215
|
+
raise
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
return await wait_on_shared_future(task)
|
|
219
|
+
|
|
220
|
+
async def _run_factory(
|
|
221
|
+
self,
|
|
222
|
+
factory: Callable[[], Awaitable[AccountRefreshResult]],
|
|
223
|
+
) -> AccountRefreshResult:
|
|
224
|
+
return await factory()
|
|
225
|
+
|
|
226
|
+
def _clear_if_current(
|
|
227
|
+
self,
|
|
228
|
+
key: Hashable,
|
|
229
|
+
task: asyncio.Task[AccountRefreshResult],
|
|
230
|
+
) -> None:
|
|
231
|
+
current = self._inflight.get(key)
|
|
232
|
+
if current is task:
|
|
233
|
+
self._inflight.pop(key, None)
|
|
234
|
+
if task.cancelled():
|
|
235
|
+
return
|
|
236
|
+
with contextlib.suppress(BaseException):
|
|
237
|
+
task.exception()
|
|
238
|
+
|
|
239
|
+
def inflight(self, account_id: Hashable) -> asyncio.Task[AccountRefreshResult] | None:
|
|
240
|
+
"""Return the running refresh task registered under ``account_id``, if any."""
|
|
241
|
+
task = self._inflight.get(account_id)
|
|
242
|
+
if task is None or task.done():
|
|
243
|
+
return None
|
|
244
|
+
return task
|
|
245
|
+
|
|
246
|
+
def clear(self) -> None:
|
|
247
|
+
self._inflight.clear()
|
|
248
|
+
|
|
249
|
+
async def cancel_all(self) -> None:
|
|
250
|
+
async with self._lock:
|
|
251
|
+
tasks = list(self._inflight.values())
|
|
252
|
+
self._inflight.clear()
|
|
253
|
+
for task in tasks:
|
|
254
|
+
task.cancel()
|
|
255
|
+
if not tasks:
|
|
256
|
+
return
|
|
257
|
+
with contextlib.suppress(BaseException):
|
|
258
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_USAGE_REFRESH_SINGLEFLIGHT = _UsageRefreshSingleflight()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _usage_refresh_singleflight_key(
|
|
265
|
+
account_id: str,
|
|
266
|
+
*,
|
|
267
|
+
own_singleflight_session: bool = False,
|
|
268
|
+
) -> str | tuple[str, str]:
|
|
269
|
+
return ("owned-session", account_id) if own_singleflight_session else account_id
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class UsageUpdater:
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
usage_repo: UsageRepositoryPort,
|
|
276
|
+
accounts_repo: AccountsRepositoryPort | None = None,
|
|
277
|
+
additional_usage_repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository | None = None,
|
|
278
|
+
*,
|
|
279
|
+
auth_manager: AuthManager | None = None,
|
|
280
|
+
) -> None:
|
|
281
|
+
self._usage_repo = usage_repo
|
|
282
|
+
self._accounts_repo = accounts_repo
|
|
283
|
+
self._additional_usage_repo = additional_usage_repo
|
|
284
|
+
self._encryptor = TokenEncryptor()
|
|
285
|
+
self._auth_manager = (
|
|
286
|
+
auth_manager if auth_manager is not None else AuthManager(accounts_repo) if accounts_repo else None
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def refresh_accounts(
|
|
290
|
+
self,
|
|
291
|
+
accounts: list[Account],
|
|
292
|
+
latest_usage: Mapping[str, UsageHistory],
|
|
293
|
+
*,
|
|
294
|
+
own_singleflight_sessions: bool = False,
|
|
295
|
+
join_existing: bool | None = None,
|
|
296
|
+
) -> bool:
|
|
297
|
+
"""Refresh usage for all accounts. Returns True if usage rows were written.
|
|
298
|
+
|
|
299
|
+
``own_singleflight_sessions`` makes each detached singleflight refresh
|
|
300
|
+
acquire and release its own DB session instead of using this updater's
|
|
301
|
+
caller-bound repositories. ``join_existing`` controls whether a caller
|
|
302
|
+
joins an in-flight refresh for the same key (deduplication) or waits
|
|
303
|
+
and forces a fresh one; it defaults to the historical coupling
|
|
304
|
+
``not own_singleflight_sessions`` so existing callers keep their
|
|
305
|
+
semantics.
|
|
306
|
+
"""
|
|
307
|
+
if join_existing is None:
|
|
308
|
+
join_existing = not own_singleflight_sessions
|
|
309
|
+
|
|
310
|
+
refreshed = False
|
|
311
|
+
now = utcnow()
|
|
312
|
+
interval = USAGE_REFRESH_INTERVAL_SECONDS
|
|
313
|
+
_prune_usage_refresh_auth_cooldowns()
|
|
314
|
+
for account in accounts:
|
|
315
|
+
if account.status in (AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED):
|
|
316
|
+
continue
|
|
317
|
+
if _is_usage_refresh_in_cooldown(account.id):
|
|
318
|
+
continue
|
|
319
|
+
latest = await self._freshness_usage_entry(account, latest_usage.get(account.id))
|
|
320
|
+
bypass_freshness = _quota_recovery_should_bypass_freshness(account, latest=latest)
|
|
321
|
+
if not bypass_freshness and await self._usage_is_fresh_with_siblings(
|
|
322
|
+
account,
|
|
323
|
+
latest,
|
|
324
|
+
now=now,
|
|
325
|
+
interval_seconds=interval,
|
|
326
|
+
):
|
|
327
|
+
continue
|
|
328
|
+
# Additional-only accounts have no main UsageHistory entry.
|
|
329
|
+
# Check DB-backed freshness (works across workers/restarts)
|
|
330
|
+
# with process-local cache as a fast path.
|
|
331
|
+
# NOTE: When a successful fetch returns empty additional data
|
|
332
|
+
# (all rows deleted), the DB has no timestamp to consult.
|
|
333
|
+
# Cross-worker may re-fetch; process-local cache (line ~138)
|
|
334
|
+
# prevents redundant calls within the same worker.
|
|
335
|
+
if latest is None:
|
|
336
|
+
last_ok = _last_successful_refresh.get(account.id)
|
|
337
|
+
if not bypass_freshness and last_ok and (now - last_ok).total_seconds() < interval:
|
|
338
|
+
continue
|
|
339
|
+
if self._additional_usage_repo is not None:
|
|
340
|
+
additional_fresh_at = await self._additional_usage_repo.latest_recorded_at_for_account(
|
|
341
|
+
account.id,
|
|
342
|
+
)
|
|
343
|
+
if (
|
|
344
|
+
not bypass_freshness
|
|
345
|
+
and additional_fresh_at
|
|
346
|
+
and (now - additional_fresh_at).total_seconds() < interval
|
|
347
|
+
):
|
|
348
|
+
_last_successful_refresh[account.id] = additional_fresh_at
|
|
349
|
+
continue
|
|
350
|
+
# NOTE: AsyncSession is not safe for concurrent use. Run sequentially
|
|
351
|
+
# within the request-scoped session to avoid PK collisions and
|
|
352
|
+
# flush-time warnings (SAWarning: Session.add during flush).
|
|
353
|
+
try:
|
|
354
|
+
if own_singleflight_sessions:
|
|
355
|
+
|
|
356
|
+
async def refresh_factory(account_id: str = account.id) -> AccountRefreshResult:
|
|
357
|
+
return await self._refresh_account_if_stale_with_owned_session(
|
|
358
|
+
account_id,
|
|
359
|
+
interval_seconds=interval,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
else:
|
|
363
|
+
|
|
364
|
+
async def refresh_factory(account: Account = account) -> AccountRefreshResult:
|
|
365
|
+
return await self._refresh_account_if_stale(
|
|
366
|
+
account,
|
|
367
|
+
usage_account_id=account.chatgpt_account_id,
|
|
368
|
+
interval_seconds=interval,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
result = await _USAGE_REFRESH_SINGLEFLIGHT.run(
|
|
372
|
+
_usage_refresh_singleflight_key(
|
|
373
|
+
account.id,
|
|
374
|
+
own_singleflight_session=own_singleflight_sessions,
|
|
375
|
+
),
|
|
376
|
+
refresh_factory,
|
|
377
|
+
join_existing=join_existing,
|
|
378
|
+
)
|
|
379
|
+
if join_existing:
|
|
380
|
+
await self._sync_account_from_repo(account)
|
|
381
|
+
refreshed = refreshed or result.usage_written
|
|
382
|
+
# Only cache when the upstream fetch actually succeeded.
|
|
383
|
+
# Transient errors (401 retry failure, 5xx, etc.) must not
|
|
384
|
+
# suppress retries within the interval.
|
|
385
|
+
if result.fetch_succeeded:
|
|
386
|
+
_last_successful_refresh[account.id] = now
|
|
387
|
+
_clear_usage_refresh_auth_cooldown(account.id)
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
logger.warning(
|
|
390
|
+
"Usage refresh failed account_id=%s request_id=%s error=%s",
|
|
391
|
+
account.id,
|
|
392
|
+
get_request_id(),
|
|
393
|
+
exc,
|
|
394
|
+
exc_info=True,
|
|
395
|
+
)
|
|
396
|
+
# swallow per-account failures so the whole refresh loop keeps going
|
|
397
|
+
continue
|
|
398
|
+
return refreshed
|
|
399
|
+
|
|
400
|
+
async def force_refresh(
|
|
401
|
+
self,
|
|
402
|
+
account: Account,
|
|
403
|
+
*,
|
|
404
|
+
access_token_override: str | None = None,
|
|
405
|
+
) -> bool:
|
|
406
|
+
"""Refresh one account regardless of cached/fresh usage rows."""
|
|
407
|
+
result = await self.force_refresh_result(account, access_token_override=access_token_override)
|
|
408
|
+
return result.usage_written
|
|
409
|
+
|
|
410
|
+
async def force_refresh_result(
|
|
411
|
+
self,
|
|
412
|
+
account: Account,
|
|
413
|
+
*,
|
|
414
|
+
access_token_override: str | None = None,
|
|
415
|
+
) -> AccountRefreshResult:
|
|
416
|
+
"""Refresh one account and expose whether the upstream fetch completed."""
|
|
417
|
+
if account.status in (AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED):
|
|
418
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
419
|
+
try:
|
|
420
|
+
result = await _USAGE_REFRESH_SINGLEFLIGHT.run(
|
|
421
|
+
account.id,
|
|
422
|
+
lambda: self._refresh_account(
|
|
423
|
+
account,
|
|
424
|
+
usage_account_id=account.chatgpt_account_id,
|
|
425
|
+
access_token_override=access_token_override,
|
|
426
|
+
),
|
|
427
|
+
join_existing=False,
|
|
428
|
+
)
|
|
429
|
+
await self._sync_account_from_repo(account)
|
|
430
|
+
if result.fetch_succeeded:
|
|
431
|
+
_last_successful_refresh[account.id] = utcnow()
|
|
432
|
+
_clear_usage_refresh_auth_cooldown(account.id)
|
|
433
|
+
return result
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
logger.warning(
|
|
436
|
+
"Forced usage refresh failed account_id=%s request_id=%s error=%s",
|
|
437
|
+
account.id,
|
|
438
|
+
get_request_id(),
|
|
439
|
+
exc,
|
|
440
|
+
exc_info=True,
|
|
441
|
+
)
|
|
442
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def request_refresh(account_id: str) -> Coroutine[Any, Any, None] | None:
|
|
446
|
+
"""Return an immediate background refresh for ``account_id``, or ``None`` when suppressed.
|
|
447
|
+
|
|
448
|
+
The caller schedules the coroutine as a tracked task. It loads the
|
|
449
|
+
account from a fresh background row (never a caller's ``Account``),
|
|
450
|
+
bypasses freshness, and joins an in-flight refresh of the account on
|
|
451
|
+
either singleflight lane (the scheduler's caller-session key or the
|
|
452
|
+
owned-session key) instead of fetching again. Repeats within
|
|
453
|
+
``_REQUEST_REFRESH_DEBOUNCE_SECONDS`` are dropped, as are accounts in
|
|
454
|
+
auth cooldown.
|
|
455
|
+
"""
|
|
456
|
+
if _is_usage_refresh_in_cooldown(account_id):
|
|
457
|
+
return None
|
|
458
|
+
now = time.monotonic()
|
|
459
|
+
deadline = _usage_request_refresh_deadlines.get(account_id)
|
|
460
|
+
if deadline is not None and deadline > now:
|
|
461
|
+
return None
|
|
462
|
+
_usage_request_refresh_deadlines[account_id] = now + _REQUEST_REFRESH_DEBOUNCE_SECONDS
|
|
463
|
+
return _run_requested_refresh(account_id)
|
|
464
|
+
|
|
465
|
+
async def _refresh_account_if_stale(
|
|
466
|
+
self,
|
|
467
|
+
account: Account,
|
|
468
|
+
*,
|
|
469
|
+
usage_account_id: str | None,
|
|
470
|
+
interval_seconds: int,
|
|
471
|
+
) -> AccountRefreshResult:
|
|
472
|
+
primary_latest = await self._usage_repo.latest_entry_for_account(account.id, window="primary")
|
|
473
|
+
latest = await self._freshness_usage_entry(account, primary_latest)
|
|
474
|
+
if not _quota_recovery_should_bypass_freshness(account, latest=latest):
|
|
475
|
+
if await self._usage_is_fresh_with_siblings(
|
|
476
|
+
account,
|
|
477
|
+
latest,
|
|
478
|
+
now=utcnow(),
|
|
479
|
+
interval_seconds=interval_seconds,
|
|
480
|
+
):
|
|
481
|
+
return AccountRefreshResult(usage_written=False)
|
|
482
|
+
return await self._refresh_account(
|
|
483
|
+
account,
|
|
484
|
+
usage_account_id=usage_account_id,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def _additional_usage_is_stale(self, account_id: str, *, now: datetime, interval_seconds: int) -> bool:
|
|
488
|
+
# The upstream fetch is the only path that syncs additional
|
|
489
|
+
# (per-model) rate limits; live main-window rows must not keep an
|
|
490
|
+
# account "fresh" while its additional rows age past the interval,
|
|
491
|
+
# or gated-model routing starves on additional_quota_data_unavailable.
|
|
492
|
+
if self._additional_usage_repo is None:
|
|
493
|
+
return False
|
|
494
|
+
latest_at = await self._additional_usage_repo.latest_recorded_at_for_account(account_id)
|
|
495
|
+
if latest_at is None:
|
|
496
|
+
# No additional rows ever fetched: live rows alone must not
|
|
497
|
+
# suppress the fetch, or a first gated-model use would never be
|
|
498
|
+
# discovered. Non-gated accounts simply keep the pre-live poll
|
|
499
|
+
# cadence.
|
|
500
|
+
return True
|
|
501
|
+
return (now - latest_at).total_seconds() >= interval_seconds
|
|
502
|
+
|
|
503
|
+
async def _freshness_usage_entry(self, account: Account, latest: UsageHistory | None) -> UsageHistory | None:
|
|
504
|
+
if latest is not None:
|
|
505
|
+
return latest
|
|
506
|
+
if usage_core.capacity_for_plan(account.plan_type, "monthly") is None:
|
|
507
|
+
return None
|
|
508
|
+
return await self._usage_repo.latest_entry_for_account(account.id, window="monthly")
|
|
509
|
+
|
|
510
|
+
async def _usage_is_fresh_with_siblings(
|
|
511
|
+
self,
|
|
512
|
+
account: Account,
|
|
513
|
+
latest: UsageHistory | None,
|
|
514
|
+
*,
|
|
515
|
+
now: datetime,
|
|
516
|
+
interval_seconds: int,
|
|
517
|
+
) -> bool:
|
|
518
|
+
if _latest_usage_is_fresh(latest, now=now, interval_seconds=interval_seconds):
|
|
519
|
+
return not await self._additional_usage_is_stale(account.id, now=now, interval_seconds=interval_seconds)
|
|
520
|
+
# A stale (or elapsed-reset) newest row of one window must not
|
|
521
|
+
# permanently defeat freshness once upstream stops reporting that
|
|
522
|
+
# window: a strictly newer sibling-window row proves a later fetch
|
|
523
|
+
# happened without rewriting the stale window, so the newest row's
|
|
524
|
+
# own freshness governs the account instead. When no primary-slot
|
|
525
|
+
# row exists at all (upstream omitted the short window from the
|
|
526
|
+
# first fetch), any sibling row is that proof.
|
|
527
|
+
newest = latest
|
|
528
|
+
for window in _MAIN_USAGE_WINDOWS:
|
|
529
|
+
if latest is not None and window == latest.window:
|
|
530
|
+
continue
|
|
531
|
+
if window == "monthly" and usage_core.capacity_for_plan(account.plan_type, "monthly") is None:
|
|
532
|
+
# A lingering monthly row from a former plan (e.g. after a
|
|
533
|
+
# free-to-paid upgrade) is not applicable usage and must not
|
|
534
|
+
# suppress refreshes.
|
|
535
|
+
continue
|
|
536
|
+
sibling = await self._usage_repo.latest_entry_for_account(account.id, window=window)
|
|
537
|
+
if sibling is not None and (newest is None or sibling.recorded_at > newest.recorded_at):
|
|
538
|
+
newest = sibling
|
|
539
|
+
if newest is None or newest is latest:
|
|
540
|
+
return False
|
|
541
|
+
if (
|
|
542
|
+
latest is not None
|
|
543
|
+
and (newest.recorded_at - latest.recorded_at).total_seconds() <= usage_core.SIBLING_FETCH_MARGIN_SECONDS
|
|
544
|
+
):
|
|
545
|
+
return False
|
|
546
|
+
if not _latest_usage_is_fresh(newest, now=now, interval_seconds=interval_seconds):
|
|
547
|
+
return False
|
|
548
|
+
return not await self._additional_usage_is_stale(account.id, now=now, interval_seconds=interval_seconds)
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
async def _load_owned_session_updater(account_id: str) -> tuple[UsageUpdater, Account] | None:
|
|
552
|
+
"""Load a fresh eligible row for ``account_id`` with repositories that own their sessions."""
|
|
553
|
+
|
|
554
|
+
@contextlib.asynccontextmanager
|
|
555
|
+
async def refresh_repo_factory():
|
|
556
|
+
yield BackgroundAccountsRepository()
|
|
557
|
+
|
|
558
|
+
accounts_repo = BackgroundAccountsRepository()
|
|
559
|
+
account = await accounts_repo.get_by_id(account_id)
|
|
560
|
+
if account is None:
|
|
561
|
+
return None
|
|
562
|
+
if account.status in (AccountStatus.PAUSED, AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED):
|
|
563
|
+
return None
|
|
564
|
+
updater = UsageUpdater(
|
|
565
|
+
BackgroundUsageRepository(),
|
|
566
|
+
accounts_repo,
|
|
567
|
+
BackgroundAdditionalUsageRepository(),
|
|
568
|
+
auth_manager=AuthManager(accounts_repo, refresh_repo_factory=refresh_repo_factory),
|
|
569
|
+
)
|
|
570
|
+
return updater, account
|
|
571
|
+
|
|
572
|
+
async def _refresh_account_if_stale_with_owned_session(
|
|
573
|
+
self,
|
|
574
|
+
account_id: str,
|
|
575
|
+
*,
|
|
576
|
+
interval_seconds: int,
|
|
577
|
+
) -> AccountRefreshResult:
|
|
578
|
+
loaded = await self._load_owned_session_updater(account_id)
|
|
579
|
+
if loaded is None:
|
|
580
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
581
|
+
updater, account = loaded
|
|
582
|
+
return await updater._refresh_account_if_stale(
|
|
583
|
+
account,
|
|
584
|
+
usage_account_id=account.chatgpt_account_id,
|
|
585
|
+
interval_seconds=interval_seconds,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
async def _refresh_account(
|
|
589
|
+
self,
|
|
590
|
+
account: Account,
|
|
591
|
+
*,
|
|
592
|
+
usage_account_id: str | None,
|
|
593
|
+
access_token_override: str | None = None,
|
|
594
|
+
) -> AccountRefreshResult:
|
|
595
|
+
access_token = access_token_override or self._encryptor.decrypt(account.access_token_encrypted)
|
|
596
|
+
payload: UsagePayload | None = None
|
|
597
|
+
try:
|
|
598
|
+
route = await _resolve_upstream_route_for_account(account, operation="usage_refresh")
|
|
599
|
+
payload = await fetch_usage(
|
|
600
|
+
access_token=access_token,
|
|
601
|
+
account_id=usage_account_id,
|
|
602
|
+
route=route,
|
|
603
|
+
allow_direct_egress=route is None,
|
|
604
|
+
)
|
|
605
|
+
except UpstreamProxyRouteError as exc:
|
|
606
|
+
logger.warning(
|
|
607
|
+
"Usage refresh upstream proxy route unavailable account_id=%s reason=%s",
|
|
608
|
+
account.id,
|
|
609
|
+
exc.reason,
|
|
610
|
+
)
|
|
611
|
+
_mark_usage_refresh_auth_cooldown(account.id, 0)
|
|
612
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
613
|
+
except UsageFetchError as exc:
|
|
614
|
+
if _should_deactivate_for_usage_error(exc):
|
|
615
|
+
await self._deactivate_for_client_error(account, exc)
|
|
616
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
617
|
+
if access_token_override is not None:
|
|
618
|
+
_mark_usage_refresh_auth_cooldown(account.id, exc.status_code)
|
|
619
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
620
|
+
if exc.status_code != 401 or not self._auth_manager:
|
|
621
|
+
_mark_usage_refresh_auth_cooldown(account.id, exc.status_code)
|
|
622
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
623
|
+
try:
|
|
624
|
+
account = await self._auth_manager.ensure_fresh(account, force=True)
|
|
625
|
+
except RefreshError:
|
|
626
|
+
_mark_usage_refresh_auth_cooldown(account.id, exc.status_code)
|
|
627
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
628
|
+
access_token = self._encryptor.decrypt(account.access_token_encrypted)
|
|
629
|
+
try:
|
|
630
|
+
route = await _resolve_upstream_route_for_account(account, operation="usage_refresh")
|
|
631
|
+
payload = await fetch_usage(
|
|
632
|
+
access_token=access_token,
|
|
633
|
+
account_id=usage_account_id,
|
|
634
|
+
route=route,
|
|
635
|
+
allow_direct_egress=route is None,
|
|
636
|
+
)
|
|
637
|
+
except UpstreamProxyRouteError as route_exc:
|
|
638
|
+
logger.warning(
|
|
639
|
+
"Usage refresh retry upstream proxy route unavailable account_id=%s reason=%s",
|
|
640
|
+
account.id,
|
|
641
|
+
route_exc.reason,
|
|
642
|
+
)
|
|
643
|
+
_mark_usage_refresh_auth_cooldown(account.id, 0)
|
|
644
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
645
|
+
except UsageFetchError as retry_exc:
|
|
646
|
+
if _should_deactivate_for_usage_error(retry_exc):
|
|
647
|
+
await self._deactivate_for_client_error(account, retry_exc)
|
|
648
|
+
else:
|
|
649
|
+
_mark_usage_refresh_auth_cooldown(account.id, retry_exc.status_code)
|
|
650
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
651
|
+
|
|
652
|
+
if payload is None:
|
|
653
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
654
|
+
|
|
655
|
+
if await _payload_mismatches_account_slot(account, payload):
|
|
656
|
+
logger.warning(
|
|
657
|
+
"Usage refresh payload identity mismatch; skipping account mutation "
|
|
658
|
+
"account_id=%s stored_workspace_id=%s payload_workspace_id=%s stored_plan_type=%s "
|
|
659
|
+
"payload_plan_type=%s stored_seat_type=%s payload_seat_type=%s request_id=%s",
|
|
660
|
+
account.id,
|
|
661
|
+
account.workspace_id,
|
|
662
|
+
payload.workspace_id,
|
|
663
|
+
account.plan_type,
|
|
664
|
+
payload.plan_type,
|
|
665
|
+
account.seat_type,
|
|
666
|
+
payload.seat_type,
|
|
667
|
+
get_request_id(),
|
|
668
|
+
)
|
|
669
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
670
|
+
|
|
671
|
+
identity_matches_slot = await self._sync_identity_metadata(account, payload)
|
|
672
|
+
if not identity_matches_slot:
|
|
673
|
+
logger.warning(
|
|
674
|
+
"Usage refresh payload reported a workspace slot owned by another account; "
|
|
675
|
+
"skipping account usage mutation account_id=%s payload_workspace_id=%s request_id=%s",
|
|
676
|
+
account.id,
|
|
677
|
+
payload.workspace_id,
|
|
678
|
+
get_request_id(),
|
|
679
|
+
)
|
|
680
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
681
|
+
|
|
682
|
+
now_epoch = _now_epoch()
|
|
683
|
+
if self._additional_usage_repo is not None:
|
|
684
|
+
if payload.additional_rate_limits:
|
|
685
|
+
merged_limits = _merge_additional_rate_limits(
|
|
686
|
+
payload.additional_rate_limits,
|
|
687
|
+
account_id=account.id,
|
|
688
|
+
now_epoch=now_epoch,
|
|
689
|
+
)
|
|
690
|
+
current_entries: set[tuple[str, str]] = set()
|
|
691
|
+
for quota_key, windows in merged_limits.items():
|
|
692
|
+
for window, merged_window in windows.items():
|
|
693
|
+
current_entries.add((quota_key, window))
|
|
694
|
+
await _add_additional_usage_entry(
|
|
695
|
+
self._additional_usage_repo,
|
|
696
|
+
account_id=account.id,
|
|
697
|
+
limit_name=merged_window.limit_name,
|
|
698
|
+
metered_feature=merged_window.metered_feature,
|
|
699
|
+
quota_key=quota_key,
|
|
700
|
+
window=window,
|
|
701
|
+
used_percent=merged_window.used_percent,
|
|
702
|
+
reset_at=merged_window.reset_at,
|
|
703
|
+
window_minutes=merged_window.window_minutes,
|
|
704
|
+
)
|
|
705
|
+
current_quota_keys = {name for name, _ in current_entries}
|
|
706
|
+
existing_quota_keys = await _list_additional_usage_quota_keys(
|
|
707
|
+
self._additional_usage_repo,
|
|
708
|
+
account_ids=[account.id],
|
|
709
|
+
)
|
|
710
|
+
for stale_key in existing_quota_keys:
|
|
711
|
+
if stale_key not in current_quota_keys:
|
|
712
|
+
await _delete_additional_usage_quota_key(
|
|
713
|
+
self._additional_usage_repo,
|
|
714
|
+
account.id,
|
|
715
|
+
stale_key,
|
|
716
|
+
)
|
|
717
|
+
continue
|
|
718
|
+
for window in ("primary", "secondary"):
|
|
719
|
+
if (stale_key, window) not in current_entries:
|
|
720
|
+
await _delete_additional_usage_quota_key_window(
|
|
721
|
+
self._additional_usage_repo,
|
|
722
|
+
account.id,
|
|
723
|
+
stale_key,
|
|
724
|
+
window,
|
|
725
|
+
)
|
|
726
|
+
elif payload.additional_rate_limits is not None:
|
|
727
|
+
await self._additional_usage_repo.delete_for_account(account.id)
|
|
728
|
+
|
|
729
|
+
rate_limit = payload.rate_limit
|
|
730
|
+
if rate_limit is None:
|
|
731
|
+
additional_synced = self._additional_usage_repo is not None and payload.additional_rate_limits is not None
|
|
732
|
+
return AccountRefreshResult(usage_written=additional_synced)
|
|
733
|
+
# Treat both None and empty rate_limit (both windows absent) as
|
|
734
|
+
# additional-only to avoid falling through to window processing.
|
|
735
|
+
normalized_windows = usage_core.normalize_rate_limit_windows(
|
|
736
|
+
rate_limit.primary_window,
|
|
737
|
+
rate_limit.secondary_window,
|
|
738
|
+
)
|
|
739
|
+
primary = normalized_windows.primary
|
|
740
|
+
secondary = normalized_windows.secondary
|
|
741
|
+
monthly = normalized_windows.monthly
|
|
742
|
+
if primary is None and secondary is None:
|
|
743
|
+
if monthly is None:
|
|
744
|
+
additional_synced = (
|
|
745
|
+
self._additional_usage_repo is not None and payload.additional_rate_limits is not None
|
|
746
|
+
)
|
|
747
|
+
return AccountRefreshResult(usage_written=additional_synced)
|
|
748
|
+
if primary is None and secondary is None and monthly is None:
|
|
749
|
+
additional_synced = self._additional_usage_repo is not None and payload.additional_rate_limits is not None
|
|
750
|
+
return AccountRefreshResult(usage_written=additional_synced)
|
|
751
|
+
credits_has, credits_unlimited, credits_balance = _credits_snapshot(payload)
|
|
752
|
+
snapshot_windows: list[UsageWindowWrite] = []
|
|
753
|
+
|
|
754
|
+
if primary and primary.used_percent is not None:
|
|
755
|
+
snapshot_windows.append(
|
|
756
|
+
UsageWindowWrite(
|
|
757
|
+
window="primary",
|
|
758
|
+
used_percent=float(primary.used_percent),
|
|
759
|
+
reset_at=_reset_at(primary.reset_at, primary.reset_after_seconds, now_epoch),
|
|
760
|
+
window_minutes=_window_minutes(primary.limit_window_seconds),
|
|
761
|
+
credits_has=credits_has,
|
|
762
|
+
credits_unlimited=credits_unlimited,
|
|
763
|
+
credits_balance=credits_balance,
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
if secondary and secondary.used_percent is not None:
|
|
768
|
+
snapshot_windows.append(
|
|
769
|
+
UsageWindowWrite(
|
|
770
|
+
window="secondary",
|
|
771
|
+
used_percent=float(secondary.used_percent),
|
|
772
|
+
reset_at=_reset_at(secondary.reset_at, secondary.reset_after_seconds, now_epoch),
|
|
773
|
+
window_minutes=_window_minutes(secondary.limit_window_seconds),
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
if monthly and monthly.used_percent is not None:
|
|
778
|
+
snapshot_windows.append(
|
|
779
|
+
UsageWindowWrite(
|
|
780
|
+
window="monthly",
|
|
781
|
+
used_percent=float(monthly.used_percent),
|
|
782
|
+
reset_at=_reset_at(monthly.reset_at, monthly.reset_after_seconds, now_epoch),
|
|
783
|
+
window_minutes=_window_minutes(monthly.limit_window_seconds),
|
|
784
|
+
credits_has=credits_has,
|
|
785
|
+
credits_unlimited=credits_unlimited,
|
|
786
|
+
credits_balance=credits_balance,
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
entries = await self._usage_repo.add_account_snapshot(
|
|
791
|
+
account.id,
|
|
792
|
+
snapshot_windows,
|
|
793
|
+
)
|
|
794
|
+
usage_written = any(_usage_entry_written(entry) for entry in entries)
|
|
795
|
+
await self._recover_quota_status_from_usage(account, primary=primary, secondary=secondary, monthly=monthly)
|
|
796
|
+
return AccountRefreshResult(usage_written=usage_written)
|
|
797
|
+
|
|
798
|
+
async def _deactivate_for_client_error(self, account: Account, exc: UsageFetchError) -> None:
|
|
799
|
+
if not self._auth_manager:
|
|
800
|
+
return
|
|
801
|
+
reason = f"Usage API error: HTTP {exc.status_code} - {exc.message}"
|
|
802
|
+
status = (
|
|
803
|
+
account_status_for_permanent_failure(exc.code)
|
|
804
|
+
if exc.code in PERMANENT_FAILURE_CODES
|
|
805
|
+
else AccountStatus.DEACTIVATED
|
|
806
|
+
)
|
|
807
|
+
logger.warning(
|
|
808
|
+
"Marking account unavailable due to client error account_id=%s account_status=%s status=%s "
|
|
809
|
+
"message=%s request_id=%s",
|
|
810
|
+
account.id,
|
|
811
|
+
status.value,
|
|
812
|
+
exc.status_code,
|
|
813
|
+
exc.message,
|
|
814
|
+
get_request_id(),
|
|
815
|
+
)
|
|
816
|
+
await self._auth_manager._repo.update_status(account.id, status, reason)
|
|
817
|
+
account.status = status
|
|
818
|
+
account.deactivation_reason = reason
|
|
819
|
+
if status == AccountStatus.DEACTIVATED:
|
|
820
|
+
mark_account_routing_unavailable(account.id)
|
|
821
|
+
get_account_selection_cache().invalidate()
|
|
822
|
+
|
|
823
|
+
async def _sync_identity_metadata(self, account: Account, payload: UsagePayload) -> bool:
|
|
824
|
+
next_plan_type = coerce_account_plan_type(payload.plan_type, account.plan_type or "free")
|
|
825
|
+
payload_workspace_id = _clean_optional(payload.workspace_id)
|
|
826
|
+
next_workspace_id = payload_workspace_id or account.workspace_id
|
|
827
|
+
next_workspace_label = _clean_optional(payload.workspace_label) or account.workspace_label
|
|
828
|
+
next_seat_type = _clean_optional(payload.seat_type) or account.seat_type
|
|
829
|
+
if self._auth_manager and payload_workspace_id and not account.workspace_id:
|
|
830
|
+
slot_taken = await self._auth_manager._repo.workspace_slot_taken(
|
|
831
|
+
account_id=account.id,
|
|
832
|
+
email=account.email,
|
|
833
|
+
chatgpt_account_id=account.chatgpt_account_id,
|
|
834
|
+
workspace_id=payload_workspace_id,
|
|
835
|
+
)
|
|
836
|
+
if slot_taken:
|
|
837
|
+
logger.warning(
|
|
838
|
+
"Usage payload reported workspace_id=%s for legacy account_id=%s, but that slot "
|
|
839
|
+
"is already owned by another account; skipping usage payload",
|
|
840
|
+
payload_workspace_id,
|
|
841
|
+
account.id,
|
|
842
|
+
)
|
|
843
|
+
return False
|
|
844
|
+
if (
|
|
845
|
+
next_plan_type == account.plan_type
|
|
846
|
+
and next_workspace_id == account.workspace_id
|
|
847
|
+
and next_workspace_label == account.workspace_label
|
|
848
|
+
and next_seat_type == account.seat_type
|
|
849
|
+
):
|
|
850
|
+
return True
|
|
851
|
+
|
|
852
|
+
account.plan_type = next_plan_type
|
|
853
|
+
account.workspace_id = next_workspace_id
|
|
854
|
+
account.workspace_label = next_workspace_label
|
|
855
|
+
account.seat_type = next_seat_type
|
|
856
|
+
if not self._auth_manager:
|
|
857
|
+
return True
|
|
858
|
+
|
|
859
|
+
# Identity/plan/workspace sync only. This runs against a stale in-memory
|
|
860
|
+
# ``account`` snapshot taken well before the write, so it MUST route
|
|
861
|
+
# through the metadata-only writer, which structurally cannot touch
|
|
862
|
+
# token ciphertext. Persisting token material from this snapshot would
|
|
863
|
+
# clobber a peer replica's concurrent refresh-token rotation.
|
|
864
|
+
await self._auth_manager._repo.update_account_metadata(
|
|
865
|
+
account.id,
|
|
866
|
+
plan_type=account.plan_type,
|
|
867
|
+
email=account.email,
|
|
868
|
+
chatgpt_account_id=account.chatgpt_account_id,
|
|
869
|
+
workspace_id=account.workspace_id,
|
|
870
|
+
workspace_label=account.workspace_label,
|
|
871
|
+
seat_type=account.seat_type,
|
|
872
|
+
)
|
|
873
|
+
return True
|
|
874
|
+
|
|
875
|
+
async def _recover_quota_status_from_usage(
|
|
876
|
+
self,
|
|
877
|
+
account: Account,
|
|
878
|
+
*,
|
|
879
|
+
primary: UsageWindow | None,
|
|
880
|
+
secondary: UsageWindow | None,
|
|
881
|
+
monthly: UsageWindow | None = None,
|
|
882
|
+
) -> None:
|
|
883
|
+
if not self._auth_manager:
|
|
884
|
+
return
|
|
885
|
+
if account.status == AccountStatus.RATE_LIMITED:
|
|
886
|
+
if account.blocked_at is not None:
|
|
887
|
+
# An account marked RATE_LIMITED by an actual 429 always
|
|
888
|
+
# carries a blocked_at marker. Honor the persisted cooldown
|
|
889
|
+
# deadline (Retry-After hint, upstream reset metadata, or the
|
|
890
|
+
# backoff floor) so the periodic usage refresh cannot flip the
|
|
891
|
+
# account back to ACTIVE — erasing reset_at that peer replicas
|
|
892
|
+
# rely on — while the upstream cooldown is still running.
|
|
893
|
+
# Fresh usage showing quota is not evidence the 429 window
|
|
894
|
+
# ended: throttles are not always quota-based. Rows without
|
|
895
|
+
# blocked_at are stale window-derived markings and keep the
|
|
896
|
+
# fresh-usage recovery below.
|
|
897
|
+
now = time.time()
|
|
898
|
+
cooldown_deadline = plausible_rate_limit_reset_at(account.reset_at, now=now) or (
|
|
899
|
+
float(account.blocked_at) + RATE_LIMITED_MIN_COOLDOWN_SECONDS
|
|
900
|
+
)
|
|
901
|
+
if now < cooldown_deadline:
|
|
902
|
+
return
|
|
903
|
+
long_window = monthly or secondary
|
|
904
|
+
if primary is None and monthly is None:
|
|
905
|
+
return
|
|
906
|
+
if primary is not None and not _window_has_available_quota(primary):
|
|
907
|
+
return
|
|
908
|
+
if primary is None and (long_window is None or not _window_has_available_quota(long_window)):
|
|
909
|
+
return
|
|
910
|
+
if long_window is not None and not _window_has_available_quota(long_window):
|
|
911
|
+
return
|
|
912
|
+
target_status = AccountStatus.ACTIVE
|
|
913
|
+
target_reset_at = None
|
|
914
|
+
expected_status = AccountStatus.RATE_LIMITED
|
|
915
|
+
elif account.status == AccountStatus.QUOTA_EXCEEDED:
|
|
916
|
+
if account.blocked_at is not None and time.time() < account.blocked_at + QUOTA_EXCEEDED_COOLDOWN_SECONDS:
|
|
917
|
+
return
|
|
918
|
+
long_window = monthly or secondary
|
|
919
|
+
windows = [window for window in (primary, long_window) if window is not None]
|
|
920
|
+
if long_window is None or not _window_has_available_quota(long_window):
|
|
921
|
+
return
|
|
922
|
+
if primary is not None and _window_is_exhausted(primary):
|
|
923
|
+
target_status = AccountStatus.RATE_LIMITED
|
|
924
|
+
target_reset_at = _reset_at(primary.reset_at, primary.reset_after_seconds, _now_epoch())
|
|
925
|
+
else:
|
|
926
|
+
if any(_window_is_exhausted(window) for window in windows):
|
|
927
|
+
return
|
|
928
|
+
target_status = AccountStatus.ACTIVE
|
|
929
|
+
target_reset_at = None
|
|
930
|
+
if not any(_window_has_available_quota(window) for window in windows):
|
|
931
|
+
return
|
|
932
|
+
expected_status = AccountStatus.QUOTA_EXCEEDED
|
|
933
|
+
else:
|
|
934
|
+
return
|
|
935
|
+
|
|
936
|
+
repo = cast(AccountsRepositoryWithStatusComparePort, self._auth_manager._repo)
|
|
937
|
+
updated = await repo.update_status_if_current(
|
|
938
|
+
account.id,
|
|
939
|
+
target_status,
|
|
940
|
+
None,
|
|
941
|
+
target_reset_at,
|
|
942
|
+
blocked_at=None,
|
|
943
|
+
expected_status=expected_status,
|
|
944
|
+
expected_deactivation_reason=account.deactivation_reason,
|
|
945
|
+
expected_reset_at=account.reset_at,
|
|
946
|
+
expected_blocked_at=account.blocked_at,
|
|
947
|
+
)
|
|
948
|
+
if not updated:
|
|
949
|
+
await self._sync_account_from_repo(account)
|
|
950
|
+
return
|
|
951
|
+
account.status = target_status
|
|
952
|
+
account.deactivation_reason = None
|
|
953
|
+
account.reset_at = target_reset_at
|
|
954
|
+
account.blocked_at = None
|
|
955
|
+
|
|
956
|
+
async def _sync_account_from_repo(self, account: Account) -> None:
|
|
957
|
+
if not self._accounts_repo:
|
|
958
|
+
return
|
|
959
|
+
# Joined owned-session refreshes run in a different session. A plain
|
|
960
|
+
# get_by_id() can return the caller session's stale identity-map row.
|
|
961
|
+
stored = await self._accounts_repo.get_by_id_fresh(account.id)
|
|
962
|
+
if stored is None:
|
|
963
|
+
return
|
|
964
|
+
account.chatgpt_account_id = stored.chatgpt_account_id
|
|
965
|
+
account.email = stored.email
|
|
966
|
+
account.workspace_id = stored.workspace_id
|
|
967
|
+
account.workspace_label = stored.workspace_label
|
|
968
|
+
account.seat_type = stored.seat_type
|
|
969
|
+
account.plan_type = stored.plan_type
|
|
970
|
+
account.access_token_encrypted = stored.access_token_encrypted
|
|
971
|
+
account.refresh_token_encrypted = stored.refresh_token_encrypted
|
|
972
|
+
account.id_token_encrypted = stored.id_token_encrypted
|
|
973
|
+
account.last_refresh = stored.last_refresh
|
|
974
|
+
account.status = stored.status
|
|
975
|
+
account.deactivation_reason = stored.deactivation_reason
|
|
976
|
+
account.reset_at = stored.reset_at
|
|
977
|
+
account.blocked_at = stored.blocked_at
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
async def _run_requested_refresh(account_id: str) -> None:
|
|
981
|
+
async def refresh_factory() -> AccountRefreshResult:
|
|
982
|
+
loaded = await UsageUpdater._load_owned_session_updater(account_id)
|
|
983
|
+
if loaded is None:
|
|
984
|
+
return AccountRefreshResult(usage_written=False, fetch_succeeded=False)
|
|
985
|
+
updater, account = loaded
|
|
986
|
+
return await updater._refresh_account(account, usage_account_id=account.chatgpt_account_id)
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
# Two singleflight lanes exist per account: the scheduler (and
|
|
990
|
+
# ``force_refresh``) run on the bare ``account_id`` key with
|
|
991
|
+
# caller-bound sessions, while the rate-limit payload and fleet paths
|
|
992
|
+
# run on the owned-session key. A request must not add a third
|
|
993
|
+
# concurrent fetch, so it joins a scheduler refresh already in flight
|
|
994
|
+
# and otherwise runs on the owned-session key, where ``join_existing``
|
|
995
|
+
# never queues a successor fetch.
|
|
996
|
+
scheduler_refresh = _USAGE_REFRESH_SINGLEFLIGHT.inflight(_usage_refresh_singleflight_key(account_id))
|
|
997
|
+
if scheduler_refresh is not None:
|
|
998
|
+
result = await wait_on_shared_future(scheduler_refresh)
|
|
999
|
+
else:
|
|
1000
|
+
result = await _USAGE_REFRESH_SINGLEFLIGHT.run(
|
|
1001
|
+
_usage_refresh_singleflight_key(account_id, own_singleflight_session=True),
|
|
1002
|
+
refresh_factory,
|
|
1003
|
+
join_existing=True,
|
|
1004
|
+
)
|
|
1005
|
+
except Exception as exc:
|
|
1006
|
+
logger.warning(
|
|
1007
|
+
"Requested usage refresh failed account_id=%s request_id=%s error=%s",
|
|
1008
|
+
account_id,
|
|
1009
|
+
get_request_id(),
|
|
1010
|
+
exc,
|
|
1011
|
+
exc_info=True,
|
|
1012
|
+
)
|
|
1013
|
+
return
|
|
1014
|
+
if result.fetch_succeeded:
|
|
1015
|
+
_last_successful_refresh[account_id] = utcnow()
|
|
1016
|
+
_clear_usage_refresh_auth_cooldown(account_id)
|
|
1017
|
+
if result.usage_written:
|
|
1018
|
+
# ``mark_rate_limit`` invalidated the selection cache before this row
|
|
1019
|
+
# existed, so a selection in between repopulated it without usage
|
|
1020
|
+
# evidence. Drop that entry now instead of letting the >= 100 % row
|
|
1021
|
+
# wait out the cache TTL before the pool reports exhaustion.
|
|
1022
|
+
get_account_selection_cache().invalidate()
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def build_background_usage_updater() -> UsageUpdater:
|
|
1026
|
+
accounts_repo = BackgroundAccountsRepository()
|
|
1027
|
+
return UsageUpdater(
|
|
1028
|
+
BackgroundUsageRepository(),
|
|
1029
|
+
accounts_repo=accounts_repo,
|
|
1030
|
+
additional_usage_repo=BackgroundAdditionalUsageRepository(),
|
|
1031
|
+
auth_manager=AuthManager(accounts_repo),
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _credits_snapshot(payload: UsagePayload) -> tuple[bool | None, bool | None, float | None]:
|
|
1036
|
+
credits = payload.credits
|
|
1037
|
+
if credits is None:
|
|
1038
|
+
return None, None, None
|
|
1039
|
+
credits_has = credits.has_credits
|
|
1040
|
+
credits_unlimited = credits.unlimited
|
|
1041
|
+
balance_value = credits.balance
|
|
1042
|
+
return credits_has, credits_unlimited, _parse_credits_balance(balance_value)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
async def _payload_mismatches_account_slot(account: Account, payload: UsagePayload) -> bool:
|
|
1046
|
+
payload_workspace_id = _clean_optional(payload.workspace_id)
|
|
1047
|
+
if account.workspace_id and payload_workspace_id and account.workspace_id != payload_workspace_id:
|
|
1048
|
+
# The payload reports a different workspace slot than the one this
|
|
1049
|
+
# account is bound to; refuse to write another workspace's usage/plan.
|
|
1050
|
+
# This guard is unconditional: repetition never makes a conflicting
|
|
1051
|
+
# workspace identity trustworthy.
|
|
1052
|
+
return True
|
|
1053
|
+
if not payload_workspace_id and payload.plan_type:
|
|
1054
|
+
payload_plan_type = coerce_account_plan_type(payload.plan_type, account.plan_type or "free")
|
|
1055
|
+
normalized_payload_plan_type = normalize_account_plan_type(payload.plan_type)
|
|
1056
|
+
stored_plan_type = coerce_account_plan_type(account.plan_type, "free")
|
|
1057
|
+
recognized_paid_plans = ACCOUNT_PLAN_TYPES - {"free"}
|
|
1058
|
+
# A transition from free into a recognized paid plan (e.g. Free -> Plus)
|
|
1059
|
+
# or between paid plans (e.g. Plus -> Pro) is a legitimate plan change,
|
|
1060
|
+
# not an identity mismatch: the usage payload carries no independent
|
|
1061
|
+
# account identifier and is fetched per-account token, so plan_type alone
|
|
1062
|
+
# cannot establish identity. A workspace-less payload that instead
|
|
1063
|
+
# introduces "free" or an unrecognized plan stays untrusted -- the
|
|
1064
|
+
# signature of a degraded or wrong-identity usage response that must not
|
|
1065
|
+
# silently rewrite the stored plan.
|
|
1066
|
+
if payload_plan_type != stored_plan_type and not (
|
|
1067
|
+
stored_plan_type == "unknown"
|
|
1068
|
+
and normalized_payload_plan_type in recognized_paid_plans
|
|
1069
|
+
or (
|
|
1070
|
+
not account.workspace_id
|
|
1071
|
+
and stored_plan_type in ACCOUNT_PLAN_TYPES
|
|
1072
|
+
and payload_plan_type in recognized_paid_plans
|
|
1073
|
+
)
|
|
1074
|
+
):
|
|
1075
|
+
# A subscription that expires reports "free" every cycle from that
|
|
1076
|
+
# account's own token, so an agreeing repeat observation is no
|
|
1077
|
+
# longer the single-sample degraded signature above. Persist the
|
|
1078
|
+
# downgrade only once it has been confirmed (issue #1456).
|
|
1079
|
+
if await _free_plan_downgrade_is_confirmed(
|
|
1080
|
+
account,
|
|
1081
|
+
stored_plan_type=stored_plan_type,
|
|
1082
|
+
normalized_payload_plan_type=normalized_payload_plan_type,
|
|
1083
|
+
):
|
|
1084
|
+
return False
|
|
1085
|
+
return True
|
|
1086
|
+
if normalize_account_plan_type(payload.plan_type) in (ACCOUNT_PLAN_TYPES - {"free"}):
|
|
1087
|
+
# The account reports a recognized paid plan, which is positive
|
|
1088
|
+
# evidence that it is still paid, so any pending downgrade evidence
|
|
1089
|
+
# is discarded. An unrecognized value is absence of evidence and
|
|
1090
|
+
# deliberately does not reach here.
|
|
1091
|
+
await _clear_workspace_less_free_plan_observations(account.id)
|
|
1092
|
+
return False
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
async def _free_plan_downgrade_is_confirmed(
|
|
1096
|
+
account: Account,
|
|
1097
|
+
*,
|
|
1098
|
+
stored_plan_type: str,
|
|
1099
|
+
normalized_payload_plan_type: str | None,
|
|
1100
|
+
) -> bool:
|
|
1101
|
+
"""Record a workspace-less paid -> free observation and report confirmation.
|
|
1102
|
+
|
|
1103
|
+
Only a recognized ``free`` payload against a recognized paid stored plan on a
|
|
1104
|
+
workspace-less account is confirmable. Unrecognized plan values never
|
|
1105
|
+
accumulate, so a degraded response reporting garbage can never downgrade an
|
|
1106
|
+
account no matter how often it repeats.
|
|
1107
|
+
"""
|
|
1108
|
+
if normalized_payload_plan_type != "free":
|
|
1109
|
+
return False
|
|
1110
|
+
if stored_plan_type not in (ACCOUNT_PLAN_TYPES - {"free"}):
|
|
1111
|
+
return False
|
|
1112
|
+
if account.workspace_id:
|
|
1113
|
+
# The account is bound to a workspace, so a payload that omits
|
|
1114
|
+
# ``workspace_id`` cannot establish that it describes this slot. A
|
|
1115
|
+
# workspace-bound seat (Team/Business/Enterprise) must not be demoted to
|
|
1116
|
+
# free on the strength of a payload that never names its workspace,
|
|
1117
|
+
# however many times it repeats.
|
|
1118
|
+
return False
|
|
1119
|
+
fingerprint = credential_fingerprint(account)
|
|
1120
|
+
store = _plan_downgrade_observation_store()
|
|
1121
|
+
# One atomic step records the observation and returns the resulting count.
|
|
1122
|
+
# The fingerprint digests the account's stable seat identity, not token
|
|
1123
|
+
# material, so routine token rotation between two observations does not
|
|
1124
|
+
# restart the sequence and a real expiry still converges (#1456). Credential
|
|
1125
|
+
# replacement (re-import or in-place reauthentication) resets pending
|
|
1126
|
+
# evidence at the replacement site itself -- the accounts repository
|
|
1127
|
+
# discards it in the same transaction that applies the fresh material --
|
|
1128
|
+
# while the fingerprint comparison here restarts the count for any
|
|
1129
|
+
# remaining path that rebinds the row's seat identity. Doing this as a read
|
|
1130
|
+
# followed by a write would leave an await between the two halves, letting
|
|
1131
|
+
# concurrent refreshes for one account lose an increment.
|
|
1132
|
+
observations = await store.observe(
|
|
1133
|
+
account.id,
|
|
1134
|
+
credential_fingerprint=fingerprint,
|
|
1135
|
+
observed_plan_type="free",
|
|
1136
|
+
)
|
|
1137
|
+
if observations < _FREE_PLAN_DOWNGRADE_CONFIRMATIONS:
|
|
1138
|
+
logger.info(
|
|
1139
|
+
"Usage refresh observed a workspace-less downgrade to free; awaiting confirmation "
|
|
1140
|
+
"account_id=%s stored_plan_type=%s observations=%s required=%s request_id=%s",
|
|
1141
|
+
account.id,
|
|
1142
|
+
stored_plan_type,
|
|
1143
|
+
observations,
|
|
1144
|
+
_FREE_PLAN_DOWNGRADE_CONFIRMATIONS,
|
|
1145
|
+
get_request_id(),
|
|
1146
|
+
)
|
|
1147
|
+
return False
|
|
1148
|
+
await store.clear(account.id)
|
|
1149
|
+
logger.info(
|
|
1150
|
+
"Usage refresh confirmed a workspace-less downgrade to free; persisting plan change "
|
|
1151
|
+
"account_id=%s stored_plan_type=%s observations=%s request_id=%s",
|
|
1152
|
+
account.id,
|
|
1153
|
+
stored_plan_type,
|
|
1154
|
+
observations,
|
|
1155
|
+
get_request_id(),
|
|
1156
|
+
)
|
|
1157
|
+
return True
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _plan_downgrade_observation_store() -> PlanDowngradeObservationStorePort:
|
|
1161
|
+
"""Resolve the shared store, falling back to process-local state.
|
|
1162
|
+
|
|
1163
|
+
The database-backed store is the process default so the observation sequence
|
|
1164
|
+
is coherent across replicas. When persistence is explicitly disabled (the
|
|
1165
|
+
DB-less unit-test harness), the fallback preserves the previous
|
|
1166
|
+
single-process behavior rather than losing the guard entirely.
|
|
1167
|
+
"""
|
|
1168
|
+
store = get_plan_downgrade_observation_store()
|
|
1169
|
+
if store is not None:
|
|
1170
|
+
return store
|
|
1171
|
+
return _FALLBACK_PLAN_DOWNGRADE_OBSERVATIONS
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
async def _clear_workspace_less_free_plan_observations(account_id: str) -> None:
|
|
1175
|
+
await _plan_downgrade_observation_store().clear(account_id)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _usage_entry_written(entry: UsageHistory | None) -> bool:
|
|
1179
|
+
return entry is not None
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _window_has_available_quota(window: UsageWindow) -> bool:
|
|
1183
|
+
used_percent = window.used_percent
|
|
1184
|
+
return used_percent is not None and float(used_percent) < 100.0
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _window_is_exhausted(window: UsageWindow) -> bool:
|
|
1188
|
+
used_percent = window.used_percent
|
|
1189
|
+
return used_percent is not None and float(used_percent) >= 100.0
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _prefer_merged_additional_window(
|
|
1193
|
+
existing: _MergedAdditionalWindow,
|
|
1194
|
+
candidate: _MergedAdditionalWindow,
|
|
1195
|
+
*,
|
|
1196
|
+
quota_key: str,
|
|
1197
|
+
window: str,
|
|
1198
|
+
) -> _MergedAdditionalWindow:
|
|
1199
|
+
if candidate.used_percent > existing.used_percent:
|
|
1200
|
+
logger.warning(
|
|
1201
|
+
"Additional usage refresh saw conflicting aliases for the same canonical quota window; "
|
|
1202
|
+
"keeping the higher usage sample account_quota=%s window=%s existing_limit=%s candidate_limit=%s "
|
|
1203
|
+
"request_id=%s",
|
|
1204
|
+
quota_key,
|
|
1205
|
+
window,
|
|
1206
|
+
existing.limit_name,
|
|
1207
|
+
candidate.limit_name,
|
|
1208
|
+
get_request_id(),
|
|
1209
|
+
)
|
|
1210
|
+
return candidate
|
|
1211
|
+
if candidate.used_percent < existing.used_percent:
|
|
1212
|
+
logger.warning(
|
|
1213
|
+
"Additional usage refresh saw conflicting aliases for the same canonical quota window; "
|
|
1214
|
+
"keeping the higher usage sample account_quota=%s window=%s existing_limit=%s candidate_limit=%s "
|
|
1215
|
+
"request_id=%s",
|
|
1216
|
+
quota_key,
|
|
1217
|
+
window,
|
|
1218
|
+
existing.limit_name,
|
|
1219
|
+
candidate.limit_name,
|
|
1220
|
+
get_request_id(),
|
|
1221
|
+
)
|
|
1222
|
+
return existing
|
|
1223
|
+
preferred = sorted(
|
|
1224
|
+
(existing, candidate),
|
|
1225
|
+
key=lambda entry: (entry.limit_name, entry.metered_feature),
|
|
1226
|
+
)[0]
|
|
1227
|
+
if preferred != existing or existing != candidate:
|
|
1228
|
+
logger.info(
|
|
1229
|
+
"Additional usage refresh coalesced duplicate aliases for canonical quota window "
|
|
1230
|
+
"account_quota=%s window=%s chosen_limit=%s request_id=%s",
|
|
1231
|
+
quota_key,
|
|
1232
|
+
window,
|
|
1233
|
+
preferred.limit_name,
|
|
1234
|
+
get_request_id(),
|
|
1235
|
+
)
|
|
1236
|
+
return preferred
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _merge_additional_rate_limits(
|
|
1240
|
+
additional_rate_limits: Collection[AdditionalRateLimitPayload],
|
|
1241
|
+
*,
|
|
1242
|
+
account_id: str,
|
|
1243
|
+
now_epoch: int,
|
|
1244
|
+
) -> dict[str, dict[str, _MergedAdditionalWindow]]:
|
|
1245
|
+
merged: dict[str, dict[str, _MergedAdditionalWindow]] = {}
|
|
1246
|
+
for additional in additional_rate_limits:
|
|
1247
|
+
limit_name = getattr(additional, "limit_name", None)
|
|
1248
|
+
metered_feature = getattr(additional, "metered_feature", None)
|
|
1249
|
+
quota_key = canonicalize_additional_quota_key(
|
|
1250
|
+
limit_name=limit_name,
|
|
1251
|
+
metered_feature=metered_feature,
|
|
1252
|
+
)
|
|
1253
|
+
if quota_key is None:
|
|
1254
|
+
logger.warning(
|
|
1255
|
+
"Skipping additional usage item without resolvable quota key "
|
|
1256
|
+
"account_id=%s limit_name=%s metered_feature=%s request_id=%s",
|
|
1257
|
+
account_id,
|
|
1258
|
+
limit_name,
|
|
1259
|
+
metered_feature,
|
|
1260
|
+
get_request_id(),
|
|
1261
|
+
)
|
|
1262
|
+
continue
|
|
1263
|
+
rate_limit = getattr(additional, "rate_limit", None)
|
|
1264
|
+
if rate_limit is None:
|
|
1265
|
+
continue
|
|
1266
|
+
for window_name, usage_window in (
|
|
1267
|
+
("primary", getattr(rate_limit, "primary_window", None)),
|
|
1268
|
+
("secondary", getattr(rate_limit, "secondary_window", None)),
|
|
1269
|
+
):
|
|
1270
|
+
if usage_window is None or usage_window.used_percent is None:
|
|
1271
|
+
continue
|
|
1272
|
+
candidate = _MergedAdditionalWindow(
|
|
1273
|
+
limit_name=str(limit_name),
|
|
1274
|
+
metered_feature=str(metered_feature),
|
|
1275
|
+
used_percent=float(usage_window.used_percent),
|
|
1276
|
+
reset_at=_reset_at(usage_window.reset_at, usage_window.reset_after_seconds, now_epoch),
|
|
1277
|
+
window_minutes=_window_minutes(usage_window.limit_window_seconds),
|
|
1278
|
+
)
|
|
1279
|
+
windows = merged.setdefault(quota_key, {})
|
|
1280
|
+
existing = windows.get(window_name)
|
|
1281
|
+
windows[window_name] = (
|
|
1282
|
+
candidate
|
|
1283
|
+
if existing is None
|
|
1284
|
+
else _prefer_merged_additional_window(
|
|
1285
|
+
existing,
|
|
1286
|
+
candidate,
|
|
1287
|
+
quota_key=quota_key,
|
|
1288
|
+
window=window_name,
|
|
1289
|
+
)
|
|
1290
|
+
)
|
|
1291
|
+
return merged
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
async def _add_additional_usage_entry(
|
|
1295
|
+
repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
|
|
1296
|
+
*,
|
|
1297
|
+
account_id: str,
|
|
1298
|
+
limit_name: str,
|
|
1299
|
+
metered_feature: str,
|
|
1300
|
+
quota_key: str,
|
|
1301
|
+
window: str,
|
|
1302
|
+
used_percent: float,
|
|
1303
|
+
reset_at: int | None,
|
|
1304
|
+
window_minutes: int | None,
|
|
1305
|
+
) -> None:
|
|
1306
|
+
await repo.add_entry(
|
|
1307
|
+
account_id=account_id,
|
|
1308
|
+
limit_name=limit_name,
|
|
1309
|
+
metered_feature=metered_feature,
|
|
1310
|
+
quota_key=quota_key,
|
|
1311
|
+
window=window,
|
|
1312
|
+
used_percent=used_percent,
|
|
1313
|
+
reset_at=reset_at,
|
|
1314
|
+
window_minutes=window_minutes,
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
async def _list_additional_usage_quota_keys(
|
|
1319
|
+
repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
|
|
1320
|
+
*,
|
|
1321
|
+
account_ids: Collection[str] | None = None,
|
|
1322
|
+
) -> list[str]:
|
|
1323
|
+
return await repo.list_quota_keys(account_ids=account_ids)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
async def _delete_additional_usage_quota_key(
|
|
1327
|
+
repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
|
|
1328
|
+
account_id: str,
|
|
1329
|
+
quota_key: str,
|
|
1330
|
+
) -> None:
|
|
1331
|
+
await repo.delete_for_account_and_quota_key(account_id, quota_key)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
async def _delete_additional_usage_quota_key_window(
|
|
1335
|
+
repo: AdditionalUsageRepositoryPort | AdditionalUsageRepository,
|
|
1336
|
+
account_id: str,
|
|
1337
|
+
quota_key: str,
|
|
1338
|
+
window: str,
|
|
1339
|
+
) -> None:
|
|
1340
|
+
await repo.delete_for_account_quota_key_window(account_id, quota_key, window)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _latest_usage_is_fresh(
|
|
1344
|
+
latest: UsageHistory | None,
|
|
1345
|
+
*,
|
|
1346
|
+
now: datetime,
|
|
1347
|
+
interval_seconds: int,
|
|
1348
|
+
) -> bool:
|
|
1349
|
+
if latest is None:
|
|
1350
|
+
return False
|
|
1351
|
+
if (now - latest.recorded_at).total_seconds() >= interval_seconds:
|
|
1352
|
+
return False
|
|
1353
|
+
if latest.reset_at is not None:
|
|
1354
|
+
now_epoch = int(now.replace(tzinfo=timezone.utc).timestamp())
|
|
1355
|
+
if now_epoch >= latest.reset_at:
|
|
1356
|
+
return False
|
|
1357
|
+
return True
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
_MAIN_USAGE_WINDOWS = ("primary", "secondary", "monthly")
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _quota_recovery_should_bypass_freshness(account: Account, *, latest: UsageHistory | None) -> bool:
|
|
1364
|
+
if _account_needs_post_reset_refresh(account, latest=latest):
|
|
1365
|
+
return True
|
|
1366
|
+
if account.status != AccountStatus.QUOTA_EXCEEDED:
|
|
1367
|
+
return False
|
|
1368
|
+
if account.blocked_at is None:
|
|
1369
|
+
return latest is None
|
|
1370
|
+
cooldown_expires_at = account.blocked_at + QUOTA_EXCEEDED_COOLDOWN_SECONDS
|
|
1371
|
+
if time.time() < cooldown_expires_at:
|
|
1372
|
+
return False
|
|
1373
|
+
if latest is None:
|
|
1374
|
+
return True
|
|
1375
|
+
recorded_at = latest.recorded_at
|
|
1376
|
+
if recorded_at.tzinfo is None:
|
|
1377
|
+
recorded_at = recorded_at.replace(tzinfo=timezone.utc)
|
|
1378
|
+
return recorded_at.timestamp() < cooldown_expires_at
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _account_needs_post_reset_refresh(account: Account, *, latest: UsageHistory | None) -> bool:
|
|
1382
|
+
if account.status not in (AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED):
|
|
1383
|
+
return False
|
|
1384
|
+
if account.reset_at is None:
|
|
1385
|
+
return False
|
|
1386
|
+
if time.time() < account.reset_at:
|
|
1387
|
+
return False
|
|
1388
|
+
if latest is None:
|
|
1389
|
+
return True
|
|
1390
|
+
recorded_at = latest.recorded_at
|
|
1391
|
+
if recorded_at.tzinfo is None:
|
|
1392
|
+
recorded_at = recorded_at.replace(tzinfo=timezone.utc)
|
|
1393
|
+
return recorded_at.timestamp() < float(account.reset_at)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _parse_credits_balance(value: str | int | float | None) -> float | None:
|
|
1397
|
+
if value is None:
|
|
1398
|
+
return None
|
|
1399
|
+
if isinstance(value, (int, float)):
|
|
1400
|
+
return float(value)
|
|
1401
|
+
if isinstance(value, str):
|
|
1402
|
+
try:
|
|
1403
|
+
return float(value.strip())
|
|
1404
|
+
except ValueError:
|
|
1405
|
+
return None
|
|
1406
|
+
return None
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _window_minutes(limit_seconds: int | None) -> int | None:
|
|
1410
|
+
if not limit_seconds or limit_seconds <= 0:
|
|
1411
|
+
return None
|
|
1412
|
+
return max(1, math.ceil(limit_seconds / 60))
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _now_epoch() -> int:
|
|
1416
|
+
return int(utcnow().replace(tzinfo=timezone.utc).timestamp())
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _reset_at(reset_at: int | None, reset_after_seconds: int | None, now_epoch: int) -> int | None:
|
|
1420
|
+
if reset_at is not None:
|
|
1421
|
+
return int(reset_at)
|
|
1422
|
+
if reset_after_seconds is None:
|
|
1423
|
+
return None
|
|
1424
|
+
return now_epoch + max(0, int(reset_after_seconds))
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
# Bare HTTP status codes from the usage endpoint are ambiguous and do not prove
|
|
1428
|
+
# that an account is permanently unavailable. Only explicit error content may
|
|
1429
|
+
# trigger an account-level status change.
|
|
1430
|
+
_DEACTIVATING_USAGE_MESSAGE_HINTS = (
|
|
1431
|
+
"your openai account has been deactivated",
|
|
1432
|
+
"account has been deactivated",
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _should_deactivate_for_usage_error(exc: UsageFetchError) -> bool:
|
|
1437
|
+
if exc.code in PERMANENT_FAILURE_CODES:
|
|
1438
|
+
return True
|
|
1439
|
+
lowered = exc.message.lower()
|
|
1440
|
+
return any(hint in lowered for hint in _DEACTIVATING_USAGE_MESSAGE_HINTS)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
async def _resolve_upstream_route_for_account(account: Account, *, operation: str) -> ResolvedUpstreamRoute | None:
|
|
1444
|
+
async with get_background_session() as session:
|
|
1445
|
+
return await resolve_upstream_route(
|
|
1446
|
+
session,
|
|
1447
|
+
account_id=account.id,
|
|
1448
|
+
operation=operation,
|
|
1449
|
+
scope="account",
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _mark_usage_refresh_auth_cooldown(account_id: str, status_code: int) -> None:
|
|
1454
|
+
if status_code not in {401, 403}:
|
|
1455
|
+
return
|
|
1456
|
+
cooldown_seconds = _USAGE_REFRESH_AUTH_FAILURE_COOLDOWN_SECONDS
|
|
1457
|
+
if cooldown_seconds <= 0:
|
|
1458
|
+
return
|
|
1459
|
+
_usage_refresh_auth_cooldowns[account_id] = time.monotonic() + cooldown_seconds
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _is_usage_refresh_in_cooldown(account_id: str) -> bool:
|
|
1463
|
+
expires_at = _usage_refresh_auth_cooldowns.get(account_id)
|
|
1464
|
+
if expires_at is None:
|
|
1465
|
+
return False
|
|
1466
|
+
if expires_at > time.monotonic():
|
|
1467
|
+
return True
|
|
1468
|
+
_usage_refresh_auth_cooldowns.pop(account_id, None)
|
|
1469
|
+
return False
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def _clear_usage_refresh_auth_cooldown(account_id: str) -> None:
|
|
1473
|
+
_usage_refresh_auth_cooldowns.pop(account_id, None)
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _prune_usage_refresh_auth_cooldowns() -> None:
|
|
1477
|
+
now = time.monotonic()
|
|
1478
|
+
stale = [account_id for account_id, expires_at in _usage_refresh_auth_cooldowns.items() if expires_at <= now]
|
|
1479
|
+
for account_id in stale:
|
|
1480
|
+
_usage_refresh_auth_cooldowns.pop(account_id, None)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _clear_usage_refresh_state() -> None:
|
|
1484
|
+
_usage_refresh_auth_cooldowns.clear()
|
|
1485
|
+
_usage_request_refresh_deadlines.clear()
|
|
1486
|
+
_last_successful_refresh.clear()
|
|
1487
|
+
_FALLBACK_PLAN_DOWNGRADE_OBSERVATIONS.clear_all()
|
|
1488
|
+
_USAGE_REFRESH_SINGLEFLIGHT.clear()
|