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,1037 @@
|
|
|
1
|
+
"""Time-axis request-usage rollups: schema primitives and repository.
|
|
2
|
+
|
|
3
|
+
Four permanent aggregate tables serve the dashboard/statistics read paths
|
|
4
|
+
without scanning raw ``request_logs``:
|
|
5
|
+
|
|
6
|
+
- ``request_usage_hourly_rollups`` — UTC hour buckets x (account_id,
|
|
7
|
+
api_key_id, model, service_tier, request_kind, is_deleted).
|
|
8
|
+
- ``request_usage_hourly_error_rollups`` — UTC hour buckets x (account_id,
|
|
9
|
+
error_code); the top-error satellite (unbounded cardinality isolated).
|
|
10
|
+
- ``request_demand_quarter_rollups`` — 900s slots x (account_id, api_key_id,
|
|
11
|
+
model, reasoning_effort, request_kind, status, is_deleted) for the quota
|
|
12
|
+
planner. The full legacy demand grain is preserved on purpose: the
|
|
13
|
+
planner's ``_bin_demand_units`` applies ``max()`` PER BIN before summing
|
|
14
|
+
(nonlinear), so folding to a coarser grain would change forecasts.
|
|
15
|
+
- ``request_conversation_hourly_rollups`` — UTC hour buckets x (normalized
|
|
16
|
+
conversation_id, account_id, is_deleted); the distinct-conversation
|
|
17
|
+
presence satellite. Folded on its OWN watermark and fold pass with the
|
|
18
|
+
shared read-side filter (normalized conversation id present, warmup kinds
|
|
19
|
+
excluded); the deleted split stays a dimension because the dashboard
|
|
20
|
+
conversation reads exclude soft-deleted rows while the reports reads
|
|
21
|
+
include them.
|
|
22
|
+
|
|
23
|
+
Watermark contract: ``account_usage_rollup_state.hourly_folded_through`` and
|
|
24
|
+
``conversation_folded_through`` (the same single state row as the lifetime
|
|
25
|
+
fold, so one ``FOR UPDATE`` row lock serializes every fold and lifecycle
|
|
26
|
+
mutation) are ALWAYS aligned to a whole UTC hour. Raw rows with
|
|
27
|
+
``requested_at < watermark`` are fully folded by that watermark's tables;
|
|
28
|
+
rows at or above it are the live tail. Buckets are half-open ``[start,
|
|
29
|
+
end)``.
|
|
30
|
+
|
|
31
|
+
NULL-dimension sentinel: nullable raw dimensions (account_id, api_key_id,
|
|
32
|
+
service_tier, reasoning_effort) are stored as ``'\\x1f'`` so they can
|
|
33
|
+
participate in the primary key on both dialects (UNIQUE/PK treat NULLs as
|
|
34
|
+
distinct rows on PostgreSQL and SQLite). The encoding is collision-free:
|
|
35
|
+
raw values that themselves start with the sentinel are escaped with one
|
|
36
|
+
more sentinel character, so the empty string — a legitimate value the
|
|
37
|
+
request models accept for service_tier/reasoning_effort, and a GROUP BY
|
|
38
|
+
group distinct from NULL in the legacy raw queries — round-trips intact.
|
|
39
|
+
Use :func:`to_dimension` / :func:`from_dimension` (SQL: ``_dimension_expr``)
|
|
40
|
+
at the write and read boundaries — a missed mapping silently diverges
|
|
41
|
+
rollups from raw.
|
|
42
|
+
|
|
43
|
+
History-rewrite discipline (MUST): any code path that mutates a folded
|
|
44
|
+
dimension (``requested_at``, ``deleted_at``, ``account_id``, ``api_key_id``,
|
|
45
|
+
``model``, ``service_tier``, ``reasoning_effort``, ``request_kind``,
|
|
46
|
+
``status``, ``error_code``, ``conversation_id``) or an aggregated measure
|
|
47
|
+
column of ``request_logs`` rows BELOW the relevant watermark must take
|
|
48
|
+
``lock_fold_state()`` in the same transaction and either mirror the mutation
|
|
49
|
+
into every rollup table that folded it or skip the pre-watermark rows
|
|
50
|
+
(folded buckets are never recomputed from raw — raw may already be pruned by
|
|
51
|
+
retention). ``RequestLogsRepository.update_model_for_request`` takes the
|
|
52
|
+
skip route (and needs no conversation-satellite bound at all: neither
|
|
53
|
+
``model`` nor ``cost_usd`` is folded there).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import logging
|
|
59
|
+
from collections.abc import Iterable, Sequence
|
|
60
|
+
from dataclasses import astuple, dataclass, replace
|
|
61
|
+
from datetime import datetime, timedelta
|
|
62
|
+
|
|
63
|
+
from sqlalchemy import BigInteger, ColumnElement, Integer, and_, case, cast, delete, func, insert, select, true, update
|
|
64
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
65
|
+
from sqlalchemy.sql.elements import SQLCoreOperations
|
|
66
|
+
|
|
67
|
+
from app.core.usage.logs import CANCELLED_STATUS, NON_ERROR_STATUSES
|
|
68
|
+
from app.core.utils.time import utcnow
|
|
69
|
+
from app.db.models import (
|
|
70
|
+
AccountUsageRollupState,
|
|
71
|
+
RequestConversationHourlyRollup,
|
|
72
|
+
RequestDemandQuarterRollup,
|
|
73
|
+
RequestLog,
|
|
74
|
+
RequestUsageHourlyErrorRollup,
|
|
75
|
+
RequestUsageHourlyRollup,
|
|
76
|
+
)
|
|
77
|
+
from app.db.session import get_background_session, sqlite_writer_section
|
|
78
|
+
from app.modules.accounts.usage_rollup import (
|
|
79
|
+
_STATE_ROW_ID,
|
|
80
|
+
FOLD_LAG,
|
|
81
|
+
_FoldStatus,
|
|
82
|
+
_insert_fn,
|
|
83
|
+
_locked_state,
|
|
84
|
+
_state_bootstrap_stmt,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
logger = logging.getLogger(__name__)
|
|
88
|
+
|
|
89
|
+
HOURLY_BUCKET_SECONDS = 3600
|
|
90
|
+
QUARTER_SLOT_SECONDS = 900
|
|
91
|
+
|
|
92
|
+
# Historical backfill folds at most this much history per slice transaction
|
|
93
|
+
# and at most TS_MAX_SLICES_PER_PASS slices per pass, so the initial backfill
|
|
94
|
+
# (millions of raw rows) spreads its I/O bursts and fold-state row-lock
|
|
95
|
+
# occupancy across scheduler ticks instead of one giant catch-up.
|
|
96
|
+
TS_FOLD_SLICE = timedelta(hours=48)
|
|
97
|
+
TS_MAX_SLICES_PER_PASS = 20
|
|
98
|
+
|
|
99
|
+
# Rolling-upgrade repair (#1552): the cancelled_count migration runs before
|
|
100
|
+
# old replicas drain, so a legacy leader can still fold hours with the old
|
|
101
|
+
# error fold (cancelled terminals counted as errors, cancelled_count 0,
|
|
102
|
+
# client_disconnected folded into the error satellite) and advance the shared
|
|
103
|
+
# watermark — buckets new code would otherwise never revisit. Old writers run
|
|
104
|
+
# old code and cannot be fenced, so the NEW code repairs instead: it refolds
|
|
105
|
+
# the legacy-suspect range `[upgrade_repair_from, hourly_folded_through)`
|
|
106
|
+
# from raw. The marker is stamped by the migration (existing rows get their
|
|
107
|
+
# then-current watermark; a state row bootstrapped by an OLD replica after
|
|
108
|
+
# the migration gets the epoch server default, marking its entire backfill
|
|
109
|
+
# suspect — a legacy leader can advance TS_MAX_SLICES_PER_PASS x
|
|
110
|
+
# TS_FOLD_SLICE per pass, so no fixed trailing window could cover it) and
|
|
111
|
+
# cleared to NULL only by new code once the range is refolded. Repair
|
|
112
|
+
# progress persists chunk-by-chunk through the marker, so a crash resumes
|
|
113
|
+
# instead of restarting.
|
|
114
|
+
#
|
|
115
|
+
# With the marker already NULL, each process's first fold pass still refolds
|
|
116
|
+
# this trailing window as flip-flop defense: an old replica that regains
|
|
117
|
+
# fold leadership AFTER the marker was cleared (mid-rollout) writes legacy
|
|
118
|
+
# buckets the marker no longer tracks. A steady-state legacy interlude
|
|
119
|
+
# advances the watermark by minutes-to-hours — far below this span — and the
|
|
120
|
+
# rollout that makes interludes possible also guarantees another new-code
|
|
121
|
+
# process (the old replica's replacement) starts afterwards and runs this
|
|
122
|
+
# defense. Cost: one backfill-slice-equivalent per process start.
|
|
123
|
+
#
|
|
124
|
+
# Both paths clamp to hours fully covered by surviving raw rows (retention
|
|
125
|
+
# prunes oldest-first with a contiguous frontier, so every row at or above
|
|
126
|
+
# the earliest surviving one is intact) — the repair can never erase folded
|
|
127
|
+
# statistics it cannot recompute; clamped-out buckets keep the disclosed
|
|
128
|
+
# legacy fold. Neither path is a historical backfill.
|
|
129
|
+
UPGRADE_REPAIR_WINDOW = timedelta(hours=48)
|
|
130
|
+
_upgrade_repair_done = False
|
|
131
|
+
|
|
132
|
+
_EPOCH = datetime(1970, 1, 1)
|
|
133
|
+
|
|
134
|
+
# Synthetic request kinds every statistics read path filters out. Folded
|
|
135
|
+
# verbatim as a dimension; readers exclude them bucket-side exactly as the
|
|
136
|
+
# legacy raw queries do row-side.
|
|
137
|
+
WARMUP_REQUEST_KINDS = ("warmup", "limit_warmup")
|
|
138
|
+
_EXCLUDED_REQUEST_KINDS = WARMUP_REQUEST_KINDS
|
|
139
|
+
|
|
140
|
+
# The whitespace set the conversation readers strip before comparing/counting
|
|
141
|
+
# conversation ids (str.strip()'s ASCII whitespace).
|
|
142
|
+
CONVERSATION_WHITESPACE = " \t\n\v\f\r"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def normalized_thread_key_expr(column: SQLCoreOperations[str | None]) -> ColumnElement:
|
|
146
|
+
"""Trim a thread-identifying text column, collapsing blank to NULL.
|
|
147
|
+
|
|
148
|
+
The single SQL definition shared by the conversation fold and every
|
|
149
|
+
conversation reader (dashboard and reports repositories) — a drifted
|
|
150
|
+
variant would silently split one conversation into two. It is reused for
|
|
151
|
+
``session_id`` so a session-keyed thread normalizes identically.
|
|
152
|
+
"""
|
|
153
|
+
trimmed = func.ltrim(func.rtrim(column, CONVERSATION_WHITESPACE), CONVERSATION_WHITESPACE)
|
|
154
|
+
return func.nullif(trimmed, "")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def conversation_id_expr() -> ColumnElement:
|
|
158
|
+
"""Normalized conversation id."""
|
|
159
|
+
return normalized_thread_key_expr(RequestLog.conversation_id)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Stored stand-in for NULL account_id / api_key_id / service_tier /
|
|
163
|
+
# reasoning_effort (PK columns cannot be NULL, and NULLs would be distinct
|
|
164
|
+
# under a unique constraint). U+001F (unit separator) rather than '' because
|
|
165
|
+
# '' is a legitimate raw value (the request models accept empty-string
|
|
166
|
+
# service_tier/reasoning_effort) that the legacy GROUP BY treats as a group
|
|
167
|
+
# distinct from NULL; raw values that start with the sentinel are escaped
|
|
168
|
+
# with one more sentinel character so the mapping stays injective.
|
|
169
|
+
DIMENSION_SENTINEL = "\x1f"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def to_dimension(value: str | None) -> str:
|
|
173
|
+
"""Map a nullable raw dimension value to its stored PK representation."""
|
|
174
|
+
if value is None:
|
|
175
|
+
return DIMENSION_SENTINEL
|
|
176
|
+
if value.startswith(DIMENSION_SENTINEL):
|
|
177
|
+
return DIMENSION_SENTINEL + value
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def from_dimension(value: str) -> str | None:
|
|
182
|
+
"""Map a stored dimension value back to the raw (nullable) domain."""
|
|
183
|
+
if value == DIMENSION_SENTINEL:
|
|
184
|
+
return None
|
|
185
|
+
if value.startswith(DIMENSION_SENTINEL):
|
|
186
|
+
return value[len(DIMENSION_SENTINEL) :]
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _dimension_expr(column) -> ColumnElement[str]:
|
|
191
|
+
"""SQL mirror of :func:`to_dimension` for the fold INSERT..SELECTs."""
|
|
192
|
+
return case(
|
|
193
|
+
(column.is_(None), DIMENSION_SENTINEL),
|
|
194
|
+
(func.substr(column, 1, 1) == DIMENSION_SENTINEL, DIMENSION_SENTINEL + column),
|
|
195
|
+
else_=column,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class HourlyUsageRollupRow:
|
|
201
|
+
bucket_epoch: int
|
|
202
|
+
account_id: str
|
|
203
|
+
api_key_id: str
|
|
204
|
+
model: str
|
|
205
|
+
service_tier: str
|
|
206
|
+
request_kind: str
|
|
207
|
+
is_deleted: bool
|
|
208
|
+
request_count: int = 0
|
|
209
|
+
error_count: int = 0
|
|
210
|
+
cancelled_count: int = 0
|
|
211
|
+
input_tokens: int = 0
|
|
212
|
+
output_tokens: int = 0
|
|
213
|
+
reasoning_tokens: int = 0
|
|
214
|
+
output_or_reasoning_tokens: int = 0
|
|
215
|
+
cached_input_tokens: int = 0
|
|
216
|
+
cached_input_tokens_clamped: int = 0
|
|
217
|
+
cost_usd: float = 0.0
|
|
218
|
+
cost_count: int = 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True, slots=True)
|
|
222
|
+
class HourlyErrorRollupRow:
|
|
223
|
+
bucket_epoch: int
|
|
224
|
+
account_id: str
|
|
225
|
+
error_code: str
|
|
226
|
+
error_count: int = 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True)
|
|
230
|
+
class QuarterDemandRollupRow:
|
|
231
|
+
slot_epoch: int
|
|
232
|
+
account_id: str
|
|
233
|
+
api_key_id: str
|
|
234
|
+
model: str
|
|
235
|
+
reasoning_effort: str
|
|
236
|
+
request_kind: str
|
|
237
|
+
status: str
|
|
238
|
+
is_deleted: bool
|
|
239
|
+
request_count: int = 0
|
|
240
|
+
input_tokens: int = 0
|
|
241
|
+
output_or_reasoning_tokens: int = 0
|
|
242
|
+
cached_input_tokens: int = 0
|
|
243
|
+
cost_usd: float = 0.0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True)
|
|
247
|
+
class ConversationHourlyRollupRow:
|
|
248
|
+
bucket_epoch: int
|
|
249
|
+
conversation_id: str
|
|
250
|
+
account_id: str
|
|
251
|
+
is_deleted: bool
|
|
252
|
+
request_count: int = 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_HOURLY_KEY_COLUMNS = (
|
|
256
|
+
"bucket_epoch",
|
|
257
|
+
"account_id",
|
|
258
|
+
"api_key_id",
|
|
259
|
+
"model",
|
|
260
|
+
"service_tier",
|
|
261
|
+
"request_kind",
|
|
262
|
+
"is_deleted",
|
|
263
|
+
)
|
|
264
|
+
_HOURLY_MEASURE_COLUMNS = (
|
|
265
|
+
"request_count",
|
|
266
|
+
"error_count",
|
|
267
|
+
"cancelled_count",
|
|
268
|
+
"input_tokens",
|
|
269
|
+
"output_tokens",
|
|
270
|
+
"reasoning_tokens",
|
|
271
|
+
"output_or_reasoning_tokens",
|
|
272
|
+
"cached_input_tokens",
|
|
273
|
+
"cached_input_tokens_clamped",
|
|
274
|
+
"cost_usd",
|
|
275
|
+
"cost_count",
|
|
276
|
+
)
|
|
277
|
+
_ERROR_KEY_COLUMNS = ("bucket_epoch", "account_id", "error_code")
|
|
278
|
+
_ERROR_MEASURE_COLUMNS = ("error_count",)
|
|
279
|
+
_QUARTER_KEY_COLUMNS = (
|
|
280
|
+
"slot_epoch",
|
|
281
|
+
"account_id",
|
|
282
|
+
"api_key_id",
|
|
283
|
+
"model",
|
|
284
|
+
"reasoning_effort",
|
|
285
|
+
"request_kind",
|
|
286
|
+
"status",
|
|
287
|
+
"is_deleted",
|
|
288
|
+
)
|
|
289
|
+
_QUARTER_MEASURE_COLUMNS = (
|
|
290
|
+
"request_count",
|
|
291
|
+
"input_tokens",
|
|
292
|
+
"output_or_reasoning_tokens",
|
|
293
|
+
"cached_input_tokens",
|
|
294
|
+
"cost_usd",
|
|
295
|
+
)
|
|
296
|
+
_CONVERSATION_KEY_COLUMNS = ("bucket_epoch", "conversation_id", "account_id", "is_deleted")
|
|
297
|
+
_CONVERSATION_MEASURE_COLUMNS = ("request_count",)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _merge_rows(rows: Iterable, key_width: int, columns: tuple[str, ...], row_type):
|
|
301
|
+
"""Pre-merge rows sharing a PK so one multi-row INSERT never touches the
|
|
302
|
+
same conflict target twice (PostgreSQL rejects that outright)."""
|
|
303
|
+
merged: dict[tuple, list] = {}
|
|
304
|
+
for row in rows:
|
|
305
|
+
values = list(astuple(row))
|
|
306
|
+
key = tuple(values[:key_width])
|
|
307
|
+
existing = merged.get(key)
|
|
308
|
+
if existing is None:
|
|
309
|
+
merged[key] = values
|
|
310
|
+
else:
|
|
311
|
+
for index in range(key_width, len(columns)):
|
|
312
|
+
existing[index] += values[index]
|
|
313
|
+
return [row_type(*values) for values in merged.values()]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Rows per multi-VALUES upsert statement. asyncpg rejects statements with
|
|
317
|
+
# more than 32,767 bind parameters; at 18 columns (the widest table) 1,000
|
|
318
|
+
# rows binds 18,000 — comfortable margin on both dialects. Lifecycle mirrors
|
|
319
|
+
# rekey an account's ENTIRE folded history in one call (thousands of rows
|
|
320
|
+
# for a long-lived account), so unchunked upserts would abort the whole
|
|
321
|
+
# lifecycle transaction.
|
|
322
|
+
_UPSERT_CHUNK_ROWS = 1_000
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _add_rows_stmt(
|
|
326
|
+
session: AsyncSession, model, rows: Sequence, key_columns: tuple[str, ...], columns: tuple[str, ...]
|
|
327
|
+
):
|
|
328
|
+
measure_columns = columns[len(key_columns) :]
|
|
329
|
+
stmt = _insert_fn(session)(model).values([dict(zip(columns, astuple(row), strict=True)) for row in rows])
|
|
330
|
+
return stmt.on_conflict_do_update(
|
|
331
|
+
index_elements=[getattr(model, column) for column in key_columns],
|
|
332
|
+
set_={column: getattr(model, column) + getattr(stmt.excluded, column) for column in measure_columns},
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def _merge_add(
|
|
337
|
+
session: AsyncSession, model, rows: Sequence, key_columns: tuple[str, ...], columns: tuple[str, ...], row_type
|
|
338
|
+
) -> None:
|
|
339
|
+
merged = _merge_rows(rows, len(key_columns), columns, row_type)
|
|
340
|
+
for start in range(0, len(merged), _UPSERT_CHUNK_ROWS):
|
|
341
|
+
chunk = merged[start : start + _UPSERT_CHUNK_ROWS]
|
|
342
|
+
await session.execute(_add_rows_stmt(session, model, chunk, key_columns, columns))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class RequestUsageTimeRollupRepository:
|
|
346
|
+
"""Upsert (merge-add) and range reads for the three time-axis rollups.
|
|
347
|
+
|
|
348
|
+
Every read returns ``(rows, hourly watermark)`` from ONE statement
|
|
349
|
+
(state LEFT JOIN rollup): a single statement sees a single snapshot even
|
|
350
|
+
under READ COMMITTED, so a fold slice committing concurrently can never
|
|
351
|
+
yield rollup rows from one watermark generation and a watermark from
|
|
352
|
+
another. Callers derive the raw-tail window from the returned watermark.
|
|
353
|
+
Range bounds are half-open ``[since_epoch, until_epoch)``.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
357
|
+
self._session = session
|
|
358
|
+
|
|
359
|
+
async def add_hourly(self, rows: Sequence[HourlyUsageRollupRow]) -> None:
|
|
360
|
+
"""Merge-add hourly rows: inserts new (bucket, dimensions) rows and
|
|
361
|
+
adds measures onto existing ones, in bounded statement chunks. Used
|
|
362
|
+
by lifecycle mirrors and tests; the fold pass itself writes via
|
|
363
|
+
DELETE-then-INSERT..SELECT."""
|
|
364
|
+
await _merge_add(
|
|
365
|
+
self._session,
|
|
366
|
+
RequestUsageHourlyRollup,
|
|
367
|
+
rows,
|
|
368
|
+
_HOURLY_KEY_COLUMNS,
|
|
369
|
+
_HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS,
|
|
370
|
+
HourlyUsageRollupRow,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def add_errors(self, rows: Sequence[HourlyErrorRollupRow]) -> None:
|
|
374
|
+
await _merge_add(
|
|
375
|
+
self._session,
|
|
376
|
+
RequestUsageHourlyErrorRollup,
|
|
377
|
+
rows,
|
|
378
|
+
_ERROR_KEY_COLUMNS,
|
|
379
|
+
_ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS,
|
|
380
|
+
HourlyErrorRollupRow,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def add_demand(self, rows: Sequence[QuarterDemandRollupRow]) -> None:
|
|
384
|
+
await _merge_add(
|
|
385
|
+
self._session,
|
|
386
|
+
RequestDemandQuarterRollup,
|
|
387
|
+
rows,
|
|
388
|
+
_QUARTER_KEY_COLUMNS,
|
|
389
|
+
_QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS,
|
|
390
|
+
QuarterDemandRollupRow,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
async def add_conversations(self, rows: Sequence[ConversationHourlyRollupRow]) -> None:
|
|
394
|
+
await _merge_add(
|
|
395
|
+
self._session,
|
|
396
|
+
RequestConversationHourlyRollup,
|
|
397
|
+
rows,
|
|
398
|
+
_CONVERSATION_KEY_COLUMNS,
|
|
399
|
+
_CONVERSATION_KEY_COLUMNS + _CONVERSATION_MEASURE_COLUMNS,
|
|
400
|
+
ConversationHourlyRollupRow,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def read_hourly(
|
|
404
|
+
self,
|
|
405
|
+
*,
|
|
406
|
+
since_epoch: int | None = None,
|
|
407
|
+
until_epoch: int | None = None,
|
|
408
|
+
filters: Sequence[ColumnElement[bool]] = (),
|
|
409
|
+
) -> tuple[list[HourlyUsageRollupRow], datetime | None]:
|
|
410
|
+
return await self._read(
|
|
411
|
+
RequestUsageHourlyRollup,
|
|
412
|
+
RequestUsageHourlyRollup.bucket_epoch,
|
|
413
|
+
_HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS,
|
|
414
|
+
HourlyUsageRollupRow,
|
|
415
|
+
since_epoch,
|
|
416
|
+
until_epoch,
|
|
417
|
+
filters,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
async def read_errors(
|
|
421
|
+
self,
|
|
422
|
+
*,
|
|
423
|
+
since_epoch: int | None = None,
|
|
424
|
+
until_epoch: int | None = None,
|
|
425
|
+
filters: Sequence[ColumnElement[bool]] = (),
|
|
426
|
+
) -> tuple[list[HourlyErrorRollupRow], datetime | None]:
|
|
427
|
+
return await self._read(
|
|
428
|
+
RequestUsageHourlyErrorRollup,
|
|
429
|
+
RequestUsageHourlyErrorRollup.bucket_epoch,
|
|
430
|
+
_ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS,
|
|
431
|
+
HourlyErrorRollupRow,
|
|
432
|
+
since_epoch,
|
|
433
|
+
until_epoch,
|
|
434
|
+
filters,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
async def read_demand(
|
|
438
|
+
self,
|
|
439
|
+
*,
|
|
440
|
+
since_epoch: int | None = None,
|
|
441
|
+
until_epoch: int | None = None,
|
|
442
|
+
filters: Sequence[ColumnElement[bool]] = (),
|
|
443
|
+
) -> tuple[list[QuarterDemandRollupRow], datetime | None]:
|
|
444
|
+
return await self._read(
|
|
445
|
+
RequestDemandQuarterRollup,
|
|
446
|
+
RequestDemandQuarterRollup.slot_epoch,
|
|
447
|
+
_QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS,
|
|
448
|
+
QuarterDemandRollupRow,
|
|
449
|
+
since_epoch,
|
|
450
|
+
until_epoch,
|
|
451
|
+
filters,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
async def _read(
|
|
455
|
+
self, model, epoch_column, columns: tuple[str, ...], row_type, since_epoch, until_epoch, filters=()
|
|
456
|
+
):
|
|
457
|
+
join_conditions = list(filters)
|
|
458
|
+
if since_epoch is not None:
|
|
459
|
+
join_conditions.append(epoch_column >= since_epoch)
|
|
460
|
+
if until_epoch is not None:
|
|
461
|
+
join_conditions.append(epoch_column < until_epoch)
|
|
462
|
+
stmt = (
|
|
463
|
+
select(
|
|
464
|
+
AccountUsageRollupState.hourly_folded_through,
|
|
465
|
+
*(getattr(model, column) for column in columns),
|
|
466
|
+
)
|
|
467
|
+
.select_from(AccountUsageRollupState)
|
|
468
|
+
.outerjoin(model, and_(*join_conditions) if join_conditions else true())
|
|
469
|
+
.where(AccountUsageRollupState.id == _STATE_ROW_ID)
|
|
470
|
+
)
|
|
471
|
+
rows = (await self._session.execute(stmt)).all()
|
|
472
|
+
if not rows:
|
|
473
|
+
return [], None
|
|
474
|
+
watermark = rows[0][0]
|
|
475
|
+
return [row_type(*row[1:]) for row in rows if row[1] is not None], watermark
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def epoch_seconds(value: datetime) -> int:
|
|
479
|
+
"""Naive-UTC datetime to Unix epoch seconds (truncating sub-seconds)."""
|
|
480
|
+
return int((value - _EPOCH).total_seconds())
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def floor_to_hour(value: datetime) -> datetime:
|
|
484
|
+
"""Floor a naive-UTC datetime to its whole UTC hour."""
|
|
485
|
+
return _EPOCH + timedelta(seconds=(epoch_seconds(value) // HOURLY_BUCKET_SECONDS) * HOURLY_BUCKET_SECONDS)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _requested_at_epoch_bucket_expr(session: AsyncSession, bucket_seconds: int) -> ColumnElement:
|
|
489
|
+
"""Dialect-split bucket expression, identical arithmetic to the runtime
|
|
490
|
+
bucketing the read paths use (`RequestLogsRepository._bucket_epoch_expr`),
|
|
491
|
+
so folded buckets and legacy raw bucketing can never disagree."""
|
|
492
|
+
dialect = session.get_bind().dialect.name
|
|
493
|
+
if dialect == "postgresql":
|
|
494
|
+
return cast(
|
|
495
|
+
func.floor(func.extract("epoch", RequestLog.requested_at) / bucket_seconds) * bucket_seconds,
|
|
496
|
+
BigInteger,
|
|
497
|
+
)
|
|
498
|
+
epoch_col = cast(func.strftime("%s", RequestLog.requested_at), Integer)
|
|
499
|
+
return cast(epoch_col / bucket_seconds, Integer) * bucket_seconds
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _hourly_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
|
|
503
|
+
bucket = _requested_at_epoch_bucket_expr(session, HOURLY_BUCKET_SECONDS).label("bucket_epoch")
|
|
504
|
+
account_id = _dimension_expr(RequestLog.account_id).label("account_id")
|
|
505
|
+
api_key_id = _dimension_expr(RequestLog.api_key_id).label("api_key_id")
|
|
506
|
+
service_tier = _dimension_expr(RequestLog.service_tier).label("service_tier")
|
|
507
|
+
is_deleted = RequestLog.deleted_at.is_not(None).label("is_deleted")
|
|
508
|
+
output_or_reasoning = func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
|
|
509
|
+
# Exact mirror of the usage-summary reader's per-row clamp
|
|
510
|
+
# (`aggregate_usage_metrics_since` / `cached_input_tokens_from_log`):
|
|
511
|
+
# NULL cached counts 0, a NULL input keeps the (non-negative) cached
|
|
512
|
+
# value UNclamped, otherwise clamp to [0, input]. SQLite's two-argument
|
|
513
|
+
# min()/max() scalar functions are its least()/greatest().
|
|
514
|
+
dialect = session.get_bind().dialect.name
|
|
515
|
+
least = func.least if dialect == "postgresql" else func.min
|
|
516
|
+
greatest = func.greatest if dialect == "postgresql" else func.max
|
|
517
|
+
cached_clamped = case(
|
|
518
|
+
(RequestLog.cached_input_tokens.is_(None), 0),
|
|
519
|
+
(RequestLog.input_tokens.is_(None), greatest(0, RequestLog.cached_input_tokens)),
|
|
520
|
+
else_=greatest(0, least(RequestLog.cached_input_tokens, RequestLog.input_tokens)),
|
|
521
|
+
)
|
|
522
|
+
stmt = (
|
|
523
|
+
select(
|
|
524
|
+
bucket,
|
|
525
|
+
account_id,
|
|
526
|
+
api_key_id,
|
|
527
|
+
RequestLog.model,
|
|
528
|
+
service_tier,
|
|
529
|
+
RequestLog.request_kind,
|
|
530
|
+
is_deleted,
|
|
531
|
+
func.count(RequestLog.id),
|
|
532
|
+
func.coalesce(func.sum(case((RequestLog.status.not_in(NON_ERROR_STATUSES), 1), else_=0)), 0),
|
|
533
|
+
func.coalesce(func.sum(case((RequestLog.status == CANCELLED_STATUS, 1), else_=0)), 0),
|
|
534
|
+
func.coalesce(func.sum(RequestLog.input_tokens), 0),
|
|
535
|
+
func.coalesce(func.sum(RequestLog.output_tokens), 0),
|
|
536
|
+
func.coalesce(func.sum(RequestLog.reasoning_tokens), 0),
|
|
537
|
+
func.coalesce(func.sum(output_or_reasoning), 0),
|
|
538
|
+
func.coalesce(func.sum(RequestLog.cached_input_tokens), 0),
|
|
539
|
+
func.coalesce(func.sum(cached_clamped), 0),
|
|
540
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0),
|
|
541
|
+
func.coalesce(func.sum(case((RequestLog.cost_usd.is_not(None), 1), else_=0)), 0),
|
|
542
|
+
)
|
|
543
|
+
.where(*window)
|
|
544
|
+
.group_by(bucket, account_id, api_key_id, RequestLog.model, service_tier, RequestLog.request_kind, is_deleted)
|
|
545
|
+
)
|
|
546
|
+
return insert(RequestUsageHourlyRollup).from_select(list(_HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS), stmt)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _error_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
|
|
550
|
+
bucket = _requested_at_epoch_bucket_expr(session, HOURLY_BUCKET_SECONDS).label("bucket_epoch")
|
|
551
|
+
account_id = _dimension_expr(RequestLog.account_id).label("account_id")
|
|
552
|
+
# Exact reproduction of the top-error read filter: warmup kinds excluded,
|
|
553
|
+
# soft-deleted rows INCLUDED, cancelled terminals excluded (they are not
|
|
554
|
+
# errors; buckets folded before that exclusion still carry
|
|
555
|
+
# client_disconnected counts, which the reads drop by error_code).
|
|
556
|
+
stmt = (
|
|
557
|
+
select(bucket, account_id, RequestLog.error_code, func.count(RequestLog.id))
|
|
558
|
+
.where(
|
|
559
|
+
*window,
|
|
560
|
+
RequestLog.request_kind.not_in(_EXCLUDED_REQUEST_KINDS),
|
|
561
|
+
RequestLog.status.not_in(NON_ERROR_STATUSES),
|
|
562
|
+
RequestLog.error_code.is_not(None),
|
|
563
|
+
)
|
|
564
|
+
.group_by(bucket, account_id, RequestLog.error_code)
|
|
565
|
+
)
|
|
566
|
+
return insert(RequestUsageHourlyErrorRollup).from_select(list(_ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS), stmt)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _demand_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
|
|
570
|
+
# Full legacy demand grain (slot, account, api_key, model,
|
|
571
|
+
# reasoning_effort, kind, status): the planner's `_bin_demand_units`
|
|
572
|
+
# takes max(token, cost, request units) PER BIN before summing, so
|
|
573
|
+
# folding to a coarser grain would shrink forecasts wherever one slot
|
|
574
|
+
# mixes groups with different dominant components.
|
|
575
|
+
slot = _requested_at_epoch_bucket_expr(session, QUARTER_SLOT_SECONDS).label("slot_epoch")
|
|
576
|
+
account_id = _dimension_expr(RequestLog.account_id).label("account_id")
|
|
577
|
+
api_key_id = _dimension_expr(RequestLog.api_key_id).label("api_key_id")
|
|
578
|
+
reasoning_effort = _dimension_expr(RequestLog.reasoning_effort).label("reasoning_effort")
|
|
579
|
+
is_deleted = RequestLog.deleted_at.is_not(None).label("is_deleted")
|
|
580
|
+
output_or_reasoning = func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
|
|
581
|
+
stmt = (
|
|
582
|
+
select(
|
|
583
|
+
slot,
|
|
584
|
+
account_id,
|
|
585
|
+
api_key_id,
|
|
586
|
+
RequestLog.model,
|
|
587
|
+
reasoning_effort,
|
|
588
|
+
RequestLog.request_kind,
|
|
589
|
+
RequestLog.status,
|
|
590
|
+
is_deleted,
|
|
591
|
+
func.count(RequestLog.id),
|
|
592
|
+
func.coalesce(func.sum(RequestLog.input_tokens), 0),
|
|
593
|
+
func.coalesce(func.sum(output_or_reasoning), 0),
|
|
594
|
+
func.coalesce(func.sum(RequestLog.cached_input_tokens), 0),
|
|
595
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0),
|
|
596
|
+
)
|
|
597
|
+
.where(*window)
|
|
598
|
+
.group_by(
|
|
599
|
+
slot,
|
|
600
|
+
account_id,
|
|
601
|
+
api_key_id,
|
|
602
|
+
RequestLog.model,
|
|
603
|
+
reasoning_effort,
|
|
604
|
+
RequestLog.request_kind,
|
|
605
|
+
RequestLog.status,
|
|
606
|
+
is_deleted,
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
return insert(RequestDemandQuarterRollup).from_select(list(_QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS), stmt)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# Shared fold filter of the conversation satellite: exactly the row set every
|
|
613
|
+
# conversation reader counts. The dashboard readers additionally require
|
|
614
|
+
# `deleted_at IS NULL` and the reports readers additionally exclude
|
|
615
|
+
# `source = 'limit_warmup'`; the former is the `is_deleted` dimension, the
|
|
616
|
+
# latter is subsumed by the request_kind filter for every row the system
|
|
617
|
+
# writes (limit-warmup traffic always carries a warmup request_kind).
|
|
618
|
+
def _conversation_fold_conditions() -> tuple[ColumnElement[bool], ...]:
|
|
619
|
+
return (
|
|
620
|
+
conversation_id_expr().is_not(None),
|
|
621
|
+
RequestLog.request_kind.not_in(_EXCLUDED_REQUEST_KINDS),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _conversation_fold_insert(session: AsyncSession, window: tuple[ColumnElement, ...]):
|
|
626
|
+
bucket = _requested_at_epoch_bucket_expr(session, HOURLY_BUCKET_SECONDS).label("bucket_epoch")
|
|
627
|
+
conversation_id = conversation_id_expr().label("conversation_id")
|
|
628
|
+
account_id = _dimension_expr(RequestLog.account_id).label("account_id")
|
|
629
|
+
is_deleted = RequestLog.deleted_at.is_not(None).label("is_deleted")
|
|
630
|
+
stmt = (
|
|
631
|
+
select(bucket, conversation_id, account_id, is_deleted, func.count(RequestLog.id))
|
|
632
|
+
.where(*window, *_conversation_fold_conditions())
|
|
633
|
+
.group_by(bucket, conversation_id, account_id, is_deleted)
|
|
634
|
+
)
|
|
635
|
+
return insert(RequestConversationHourlyRollup).from_select(
|
|
636
|
+
list(_CONVERSATION_KEY_COLUMNS + _CONVERSATION_MEASURE_COLUMNS), stmt
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
async def run_hourly_fold_pass(*, now: datetime | None = None) -> int:
|
|
641
|
+
"""Advance the hourly watermark toward `floor_hour(now - FOLD_LAG)`.
|
|
642
|
+
|
|
643
|
+
Bounded slices, each committed in its own transaction; at most
|
|
644
|
+
`TS_MAX_SLICES_PER_PASS` slices per pass, so the initial backfill resumes
|
|
645
|
+
across scheduler ticks instead of monopolizing one. Returns the number of
|
|
646
|
+
committed slices. Crash-safe: the defensive DELETE, the three
|
|
647
|
+
INSERT..SELECTs and the watermark advance commit atomically, so a crash
|
|
648
|
+
rolls the whole slice back and the retry recomputes it from scratch —
|
|
649
|
+
re-folding always converges to the same values (no add-fold double
|
|
650
|
+
counting is possible).
|
|
651
|
+
|
|
652
|
+
The first pass of each process additionally repairs the legacy-suspect
|
|
653
|
+
range `[upgrade_repair_from, watermark)` — buckets a legacy replica may
|
|
654
|
+
have folded after the cancelled_count migration during a rolling
|
|
655
|
+
upgrade — falling back to the trailing `UPGRADE_REPAIR_WINDOW` as
|
|
656
|
+
flip-flop defense once the marker is cleared (see the constant's note).
|
|
657
|
+
An incomplete (pass-bounded) repair re-arms itself for the next pass.
|
|
658
|
+
"""
|
|
659
|
+
global _upgrade_repair_done
|
|
660
|
+
if not _upgrade_repair_done:
|
|
661
|
+
_upgrade_repair_done = await _run_upgrade_repair()
|
|
662
|
+
target = floor_to_hour((now or utcnow()) - FOLD_LAG)
|
|
663
|
+
committed = 0
|
|
664
|
+
while committed < TS_MAX_SLICES_PER_PASS:
|
|
665
|
+
async with get_background_session() as session:
|
|
666
|
+
status, wrote = await _fold_next_hourly_slice(session, target)
|
|
667
|
+
if wrote:
|
|
668
|
+
committed += 1
|
|
669
|
+
if status is _FoldStatus.DONE:
|
|
670
|
+
break
|
|
671
|
+
return committed
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _ceil_to_hour(value: datetime) -> datetime:
|
|
675
|
+
floored = floor_to_hour(value)
|
|
676
|
+
return floored if floored == value else floored + timedelta(hours=1)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
async def _run_upgrade_repair() -> bool:
|
|
680
|
+
"""Drive the upgrade repair in pass-bounded chunks; True when complete.
|
|
681
|
+
|
|
682
|
+
Same chunk/transaction discipline as the fold backfill: at most
|
|
683
|
+
`TS_MAX_SLICES_PER_PASS` chunks of `TS_FOLD_SLICE` per pass, each in its
|
|
684
|
+
own transaction, so repairing a legacy-folded backlog never monopolizes
|
|
685
|
+
a scheduler tick. An incomplete repair returns False and resumes from
|
|
686
|
+
the persisted marker on the next pass.
|
|
687
|
+
"""
|
|
688
|
+
for _ in range(TS_MAX_SLICES_PER_PASS):
|
|
689
|
+
async with get_background_session() as session:
|
|
690
|
+
if await _repair_next_upgrade_chunk(session):
|
|
691
|
+
return True
|
|
692
|
+
return False
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
async def _repair_next_upgrade_chunk(session: AsyncSession) -> bool:
|
|
696
|
+
"""Refold the next chunk of the legacy-suspect range; True when none left.
|
|
697
|
+
|
|
698
|
+
The suspect range is `[upgrade_repair_from, watermark)` while the marker
|
|
699
|
+
is set (stamped by the migration, or the epoch server default for a
|
|
700
|
+
state row an old replica bootstrapped after it), and the trailing
|
|
701
|
+
`UPGRADE_REPAIR_WINDOW` flip-flop defense once it is NULL. Both are
|
|
702
|
+
clamped to `ceil_hour(earliest surviving raw row)` — an unfiltered min:
|
|
703
|
+
retention's oldest-first contiguous frontier guarantees every row
|
|
704
|
+
(soft-deleted included) at or above it survives, so the repair never
|
|
705
|
+
deletes folded statistics it cannot recompute from raw.
|
|
706
|
+
|
|
707
|
+
Runs under the fold-state row lock (the caller already holds the leader
|
|
708
|
+
gate), so it can never interleave with a fold slice or lifecycle mirror,
|
|
709
|
+
and never moves the watermark. The same DELETE-then-INSERT statements as
|
|
710
|
+
the fold make every chunk converge on any input state — repairing a
|
|
711
|
+
legacy-folded bucket, re-running after a crash (the marker advances only
|
|
712
|
+
with the chunk's commit), or recomputing an already-correct window are
|
|
713
|
+
the same recomputation. The conversation satellite is untouched: its
|
|
714
|
+
fold never involved status.
|
|
715
|
+
"""
|
|
716
|
+
async with sqlite_writer_section():
|
|
717
|
+
state = await _locked_state(session)
|
|
718
|
+
if state is None:
|
|
719
|
+
# Nothing folded yet, so nothing a legacy writer could have left.
|
|
720
|
+
return True
|
|
721
|
+
watermark = state.hourly_folded_through
|
|
722
|
+
marker = state.upgrade_repair_from
|
|
723
|
+
suspect_from = marker if marker is not None else watermark - UPGRADE_REPAIR_WINDOW
|
|
724
|
+
earliest_raw = (await session.execute(select(func.min(RequestLog.requested_at)))).scalar_one_or_none()
|
|
725
|
+
start = watermark if earliest_raw is None else max(suspect_from, _ceil_to_hour(earliest_raw))
|
|
726
|
+
if start >= watermark:
|
|
727
|
+
if marker is not None:
|
|
728
|
+
await _set_upgrade_repair_marker(session, None)
|
|
729
|
+
await session.commit()
|
|
730
|
+
return True
|
|
731
|
+
chunk_end = min(start + TS_FOLD_SLICE, watermark)
|
|
732
|
+
start_epoch, end_epoch = epoch_seconds(start), epoch_seconds(chunk_end)
|
|
733
|
+
await session.execute(
|
|
734
|
+
delete(RequestUsageHourlyRollup).where(
|
|
735
|
+
RequestUsageHourlyRollup.bucket_epoch >= start_epoch,
|
|
736
|
+
RequestUsageHourlyRollup.bucket_epoch < end_epoch,
|
|
737
|
+
)
|
|
738
|
+
)
|
|
739
|
+
await session.execute(
|
|
740
|
+
delete(RequestUsageHourlyErrorRollup).where(
|
|
741
|
+
RequestUsageHourlyErrorRollup.bucket_epoch >= start_epoch,
|
|
742
|
+
RequestUsageHourlyErrorRollup.bucket_epoch < end_epoch,
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
await session.execute(
|
|
746
|
+
delete(RequestDemandQuarterRollup).where(
|
|
747
|
+
RequestDemandQuarterRollup.slot_epoch >= start_epoch,
|
|
748
|
+
RequestDemandQuarterRollup.slot_epoch < end_epoch,
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
window = (RequestLog.requested_at >= start, RequestLog.requested_at < chunk_end)
|
|
752
|
+
await session.execute(_hourly_fold_insert(session, window))
|
|
753
|
+
await session.execute(_error_fold_insert(session, window))
|
|
754
|
+
await session.execute(_demand_fold_insert(session, window))
|
|
755
|
+
done = chunk_end >= watermark
|
|
756
|
+
if marker is not None:
|
|
757
|
+
await _set_upgrade_repair_marker(session, None if done else chunk_end)
|
|
758
|
+
await session.commit()
|
|
759
|
+
logger.info(
|
|
760
|
+
"Refolded hourly usage rollups in [%s, %s) (post-upgrade repair)",
|
|
761
|
+
start.isoformat(),
|
|
762
|
+
chunk_end.isoformat(),
|
|
763
|
+
)
|
|
764
|
+
return done
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
async def _set_upgrade_repair_marker(session: AsyncSession, value: datetime | None) -> None:
|
|
768
|
+
await session.execute(
|
|
769
|
+
update(AccountUsageRollupState)
|
|
770
|
+
.where(AccountUsageRollupState.id == _STATE_ROW_ID)
|
|
771
|
+
.values(upgrade_repair_from=value)
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def _fold_next_hourly_slice(session: AsyncSession, target: datetime) -> tuple[_FoldStatus, bool]:
|
|
776
|
+
async with sqlite_writer_section():
|
|
777
|
+
# Same state row (id=1) as the lifetime fold: one FOR UPDATE row lock
|
|
778
|
+
# serializes concurrent hourly passes, lifetime passes, and lifecycle
|
|
779
|
+
# mirrors. Re-read the watermark AFTER taking the lock — a concurrent
|
|
780
|
+
# pass may have advanced it while we waited.
|
|
781
|
+
state = await _locked_state(session)
|
|
782
|
+
if state is None:
|
|
783
|
+
await session.execute(_state_bootstrap_stmt(session))
|
|
784
|
+
await session.commit()
|
|
785
|
+
state = await _locked_state(session)
|
|
786
|
+
if state is None:
|
|
787
|
+
logger.warning("account_usage_rollup_state row missing; skipping hourly fold pass")
|
|
788
|
+
return _FoldStatus.DONE, False
|
|
789
|
+
watermark = state.hourly_folded_through
|
|
790
|
+
if watermark >= target:
|
|
791
|
+
return _FoldStatus.DONE, False
|
|
792
|
+
|
|
793
|
+
# Next populated instant in [watermark, target): jumps the empty
|
|
794
|
+
# prefix on first backfill and any mid-history gap on later slices,
|
|
795
|
+
# and guarantees the slice's first hour actually holds rows. Every
|
|
796
|
+
# row counts for at least one of the three aggregates (the hourly
|
|
797
|
+
# fold has no filter), so no filtered variant is needed.
|
|
798
|
+
next_populated = (
|
|
799
|
+
await session.execute(
|
|
800
|
+
select(func.min(RequestLog.requested_at)).where(
|
|
801
|
+
RequestLog.requested_at >= watermark,
|
|
802
|
+
RequestLog.requested_at < target,
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
).scalar_one_or_none()
|
|
806
|
+
if next_populated is None:
|
|
807
|
+
# Nothing left below the target. Advancing (rather than leaving
|
|
808
|
+
# the watermark behind) keeps the readers' raw-tail window and
|
|
809
|
+
# the retention min-gate current; FOLD_LAG guarantees no insert
|
|
810
|
+
# can land below `now - FOLD_LAG`, so nothing can appear behind
|
|
811
|
+
# the advanced watermark later.
|
|
812
|
+
await _advance_hourly_watermark(session, target)
|
|
813
|
+
await session.commit()
|
|
814
|
+
logger.info("Folded hourly usage rollups through %s (no rows below target)", target.isoformat())
|
|
815
|
+
return _FoldStatus.DONE, True
|
|
816
|
+
|
|
817
|
+
start = max(watermark, floor_to_hour(next_populated))
|
|
818
|
+
slice_end = min(start + TS_FOLD_SLICE, target)
|
|
819
|
+
start_epoch, end_epoch = epoch_seconds(start), epoch_seconds(slice_end)
|
|
820
|
+
|
|
821
|
+
# Defensive DELETE: zero rows on the normal path (the watermark only
|
|
822
|
+
# moves forward), but makes an operator watermark reset (escape
|
|
823
|
+
# hatch) converge — a re-fold can never double-count or leave rows
|
|
824
|
+
# from a previous fold generation behind. Convergence is guaranteed
|
|
825
|
+
# under the escape hatch's documented precondition (raw below the
|
|
826
|
+
# target still present, or the reset truncated the rollups in the
|
|
827
|
+
# same transaction): every previously-folded hour then either has
|
|
828
|
+
# raw rows (re-covered by a slice window and its DELETE) or no
|
|
829
|
+
# rollup rows. Hours the min()-jump skips are deliberately NOT
|
|
830
|
+
# cleared — after a rewind-only reset over retention-pruned history
|
|
831
|
+
# (the documented forbidden state) the skipped rollup rows are the
|
|
832
|
+
# ONLY surviving copy of those statistics, and deleting them would
|
|
833
|
+
# turn an operator mistake into permanent data loss.
|
|
834
|
+
await session.execute(
|
|
835
|
+
delete(RequestUsageHourlyRollup).where(
|
|
836
|
+
RequestUsageHourlyRollup.bucket_epoch >= start_epoch,
|
|
837
|
+
RequestUsageHourlyRollup.bucket_epoch < end_epoch,
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
await session.execute(
|
|
841
|
+
delete(RequestUsageHourlyErrorRollup).where(
|
|
842
|
+
RequestUsageHourlyErrorRollup.bucket_epoch >= start_epoch,
|
|
843
|
+
RequestUsageHourlyErrorRollup.bucket_epoch < end_epoch,
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
await session.execute(
|
|
847
|
+
delete(RequestDemandQuarterRollup).where(
|
|
848
|
+
RequestDemandQuarterRollup.slot_epoch >= start_epoch,
|
|
849
|
+
RequestDemandQuarterRollup.slot_epoch < end_epoch,
|
|
850
|
+
)
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# Half-open [start, slice_end): hour-aligned bounds, so a display
|
|
854
|
+
# bucket is never split between the folded side and the raw tail.
|
|
855
|
+
window = (RequestLog.requested_at >= start, RequestLog.requested_at < slice_end)
|
|
856
|
+
await session.execute(_hourly_fold_insert(session, window))
|
|
857
|
+
await session.execute(_error_fold_insert(session, window))
|
|
858
|
+
await session.execute(_demand_fold_insert(session, window))
|
|
859
|
+
await _advance_hourly_watermark(session, slice_end)
|
|
860
|
+
await session.commit()
|
|
861
|
+
logger.info("Folded hourly usage rollups through %s", slice_end.isoformat())
|
|
862
|
+
return (_FoldStatus.DONE if slice_end >= target else _FoldStatus.CONTINUE), True
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
async def _advance_hourly_watermark(session: AsyncSession, value: datetime) -> None:
|
|
866
|
+
await session.execute(
|
|
867
|
+
update(AccountUsageRollupState)
|
|
868
|
+
.where(AccountUsageRollupState.id == _STATE_ROW_ID)
|
|
869
|
+
.values(hourly_folded_through=value)
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
async def run_conversation_fold_pass(*, now: datetime | None = None) -> int:
|
|
874
|
+
"""Advance the conversation watermark toward `floor_hour(now - FOLD_LAG)`.
|
|
875
|
+
|
|
876
|
+
Same slice/transaction/crash-safety contract as `run_hourly_fold_pass`
|
|
877
|
+
(DELETE-then-INSERT slices, bounded per pass, watermark advance committed
|
|
878
|
+
atomically with the slice), on the satellite's own watermark so its
|
|
879
|
+
from-epoch backfill neither rewinds nor stalls the other rollups.
|
|
880
|
+
"""
|
|
881
|
+
target = floor_to_hour((now or utcnow()) - FOLD_LAG)
|
|
882
|
+
committed = 0
|
|
883
|
+
while committed < TS_MAX_SLICES_PER_PASS:
|
|
884
|
+
async with get_background_session() as session:
|
|
885
|
+
status, wrote = await _fold_next_conversation_slice(session, target)
|
|
886
|
+
if wrote:
|
|
887
|
+
committed += 1
|
|
888
|
+
if status is _FoldStatus.DONE:
|
|
889
|
+
break
|
|
890
|
+
return committed
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
async def _fold_next_conversation_slice(session: AsyncSession, target: datetime) -> tuple[_FoldStatus, bool]:
|
|
894
|
+
async with sqlite_writer_section():
|
|
895
|
+
# Same state row lock as every other fold and lifecycle mirror.
|
|
896
|
+
state = await _locked_state(session)
|
|
897
|
+
if state is None:
|
|
898
|
+
await session.execute(_state_bootstrap_stmt(session))
|
|
899
|
+
await session.commit()
|
|
900
|
+
state = await _locked_state(session)
|
|
901
|
+
if state is None:
|
|
902
|
+
logger.warning("account_usage_rollup_state row missing; skipping conversation fold pass")
|
|
903
|
+
return _FoldStatus.DONE, False
|
|
904
|
+
watermark = state.conversation_folded_through
|
|
905
|
+
if watermark >= target:
|
|
906
|
+
return _FoldStatus.DONE, False
|
|
907
|
+
|
|
908
|
+
# Unlike the hourly fold (which folds every row), only rows matching
|
|
909
|
+
# the fold filter contribute here, so the empty-prefix/gap jump scans
|
|
910
|
+
# for the next COUNTABLE row — hours holding only conversation-less
|
|
911
|
+
# rows are skipped exactly like empty ones.
|
|
912
|
+
next_populated = (
|
|
913
|
+
await session.execute(
|
|
914
|
+
select(func.min(RequestLog.requested_at)).where(
|
|
915
|
+
RequestLog.requested_at >= watermark,
|
|
916
|
+
RequestLog.requested_at < target,
|
|
917
|
+
*_conversation_fold_conditions(),
|
|
918
|
+
)
|
|
919
|
+
)
|
|
920
|
+
).scalar_one_or_none()
|
|
921
|
+
if next_populated is None:
|
|
922
|
+
await _advance_conversation_watermark(session, target)
|
|
923
|
+
await session.commit()
|
|
924
|
+
logger.info("Folded conversation rollups through %s (no rows below target)", target.isoformat())
|
|
925
|
+
return _FoldStatus.DONE, True
|
|
926
|
+
|
|
927
|
+
start = max(watermark, floor_to_hour(next_populated))
|
|
928
|
+
slice_end = min(start + TS_FOLD_SLICE, target)
|
|
929
|
+
start_epoch, end_epoch = epoch_seconds(start), epoch_seconds(slice_end)
|
|
930
|
+
|
|
931
|
+
# Defensive DELETE with the same convergence/preservation trade-off
|
|
932
|
+
# as the hourly slice: skipped hours are deliberately NOT cleared.
|
|
933
|
+
await session.execute(
|
|
934
|
+
delete(RequestConversationHourlyRollup).where(
|
|
935
|
+
RequestConversationHourlyRollup.bucket_epoch >= start_epoch,
|
|
936
|
+
RequestConversationHourlyRollup.bucket_epoch < end_epoch,
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
window = (RequestLog.requested_at >= start, RequestLog.requested_at < slice_end)
|
|
940
|
+
await session.execute(_conversation_fold_insert(session, window))
|
|
941
|
+
await _advance_conversation_watermark(session, slice_end)
|
|
942
|
+
await session.commit()
|
|
943
|
+
logger.info("Folded conversation rollups through %s", slice_end.isoformat())
|
|
944
|
+
return (_FoldStatus.DONE if slice_end >= target else _FoldStatus.CONTINUE), True
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
async def _advance_conversation_watermark(session: AsyncSession, value: datetime) -> None:
|
|
948
|
+
await session.execute(
|
|
949
|
+
update(AccountUsageRollupState)
|
|
950
|
+
.where(AccountUsageRollupState.id == _STATE_ROW_ID)
|
|
951
|
+
.values(conversation_folded_through=value)
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
# --- Account lifecycle mirrors -------------------------------------------
|
|
956
|
+
#
|
|
957
|
+
# The ONLY code paths allowed to touch folded buckets after the watermark
|
|
958
|
+
# passed them. They mirror the raw request_logs mutation exactly (a dimension
|
|
959
|
+
# move, never a recompute — raw below the watermark may already be pruned).
|
|
960
|
+
# Callers MUST hold the fold-state lock (`lock_fold_state`) in the same
|
|
961
|
+
# transaction, so a mirror can never interleave with an in-flight fold slice.
|
|
962
|
+
|
|
963
|
+
_ROLLUP_TABLES = (
|
|
964
|
+
(RequestUsageHourlyRollup, _HOURLY_KEY_COLUMNS + _HOURLY_MEASURE_COLUMNS, HourlyUsageRollupRow, "add_hourly"),
|
|
965
|
+
(RequestUsageHourlyErrorRollup, _ERROR_KEY_COLUMNS + _ERROR_MEASURE_COLUMNS, HourlyErrorRollupRow, "add_errors"),
|
|
966
|
+
(RequestDemandQuarterRollup, _QUARTER_KEY_COLUMNS + _QUARTER_MEASURE_COLUMNS, QuarterDemandRollupRow, "add_demand"),
|
|
967
|
+
(
|
|
968
|
+
RequestConversationHourlyRollup,
|
|
969
|
+
_CONVERSATION_KEY_COLUMNS + _CONVERSATION_MEASURE_COLUMNS,
|
|
970
|
+
ConversationHourlyRollupRow,
|
|
971
|
+
"add_conversations",
|
|
972
|
+
),
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
async def _rekey_account_rows(session: AsyncSession, account_ids: list[str], rekey) -> None:
|
|
977
|
+
repo = RequestUsageTimeRollupRepository(session)
|
|
978
|
+
stored_ids = [to_dimension(account_id) for account_id in account_ids]
|
|
979
|
+
for model, columns, row_type, adder in _ROLLUP_TABLES:
|
|
980
|
+
stmt = select(*(getattr(model, column) for column in columns)).where(model.account_id.in_(stored_ids))
|
|
981
|
+
rows = [row_type(*row) for row in (await session.execute(stmt)).all()]
|
|
982
|
+
if not rows:
|
|
983
|
+
continue
|
|
984
|
+
await session.execute(delete(model).where(model.account_id.in_(stored_ids)))
|
|
985
|
+
await getattr(repo, adder)([rekey(row) for row in rows])
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
async def mirror_account_soft_delete_into_time_rollups(session: AsyncSession, account_id: str) -> None:
|
|
989
|
+
"""Mirror `AccountsRepository.delete()`'s soft path, which retroactively
|
|
990
|
+
detaches the account's ENTIRE raw history (`account_id=NULL,
|
|
991
|
+
deleted_at=now`): folded buckets move to the `(NULL-sentinel,
|
|
992
|
+
is_deleted=true)` dimension (merge-added — an orphaned-deleted bucket
|
|
993
|
+
may already exist).
|
|
994
|
+
The error satellite has no `is_deleted` dimension (its read includes
|
|
995
|
+
soft-deleted rows), so only `account_id` is re-keyed there. The
|
|
996
|
+
conversation satellite carries `account_id` precisely so this mirror can
|
|
997
|
+
re-attribute presence the way the raw UPDATE does — the dashboard
|
|
998
|
+
conversation reads (which exclude soft-deleted rows) then stop counting
|
|
999
|
+
it while the reports reads (which include them) keep it.
|
|
1000
|
+
"""
|
|
1001
|
+
|
|
1002
|
+
def _rekey(row):
|
|
1003
|
+
if isinstance(row, HourlyErrorRollupRow):
|
|
1004
|
+
return replace(row, account_id=DIMENSION_SENTINEL)
|
|
1005
|
+
return replace(row, account_id=DIMENSION_SENTINEL, is_deleted=True)
|
|
1006
|
+
|
|
1007
|
+
await _rekey_account_rows(session, [account_id], _rekey)
|
|
1008
|
+
from app.modules.reports.rollup import rekey_report_accounts
|
|
1009
|
+
|
|
1010
|
+
await rekey_report_accounts(session, [account_id], None)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
async def mirror_account_hard_delete_into_time_rollups(session: AsyncSession, account_id: str) -> None:
|
|
1014
|
+
"""Mirror the history-deleting path (raw rows physically removed)."""
|
|
1015
|
+
from app.db.models import RequestReportHourlyRollup
|
|
1016
|
+
|
|
1017
|
+
await session.execute(
|
|
1018
|
+
delete(RequestReportHourlyRollup).where(RequestReportHourlyRollup.account_id == to_dimension(account_id))
|
|
1019
|
+
)
|
|
1020
|
+
for model, *_rest in _ROLLUP_TABLES:
|
|
1021
|
+
await session.execute(delete(model).where(model.account_id == to_dimension(account_id)))
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
async def merge_time_rollups_into(session: AsyncSession, canonical_account_id: str, duplicate_ids: list[str]) -> None:
|
|
1025
|
+
"""Mirror duplicate-account consolidation, which reassigns the
|
|
1026
|
+
duplicates' raw logs to the canonical account: folded buckets follow
|
|
1027
|
+
bucket-wise (merge-add onto the canonical dimension, then the duplicate
|
|
1028
|
+
rows are removed). Must run in the consolidation transaction, under the
|
|
1029
|
+
fold-state lock the caller already holds.
|
|
1030
|
+
"""
|
|
1031
|
+
if not duplicate_ids:
|
|
1032
|
+
return
|
|
1033
|
+
from app.modules.reports.rollup import rekey_report_accounts
|
|
1034
|
+
|
|
1035
|
+
await rekey_report_accounts(session, duplicate_ids, canonical_account_id)
|
|
1036
|
+
canonical_dimension = to_dimension(canonical_account_id)
|
|
1037
|
+
await _rekey_account_rows(session, duplicate_ids, lambda row: replace(row, account_id=canonical_dimension))
|