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,1890 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from collections.abc import Collection, Mapping
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from functools import partial
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from threading import RLock
|
|
11
|
+
from typing import Any, Callable, Literal, cast
|
|
12
|
+
|
|
13
|
+
from anyio import to_thread
|
|
14
|
+
from sqlalchemy import (
|
|
15
|
+
Integer,
|
|
16
|
+
String,
|
|
17
|
+
and_,
|
|
18
|
+
case,
|
|
19
|
+
column,
|
|
20
|
+
delete,
|
|
21
|
+
func,
|
|
22
|
+
literal_column,
|
|
23
|
+
or_,
|
|
24
|
+
select,
|
|
25
|
+
text,
|
|
26
|
+
true,
|
|
27
|
+
tuple_,
|
|
28
|
+
union_all,
|
|
29
|
+
values,
|
|
30
|
+
)
|
|
31
|
+
from sqlalchemy import cast as sqlalchemy_cast
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
33
|
+
|
|
34
|
+
from app.core.config.settings import get_settings
|
|
35
|
+
from app.core.usage.types import UsageAggregateRow, UsageTrendBucket
|
|
36
|
+
from app.core.utils.time import utcnow
|
|
37
|
+
from app.db.account_identity_lock import lock_postgresql_account_identities
|
|
38
|
+
from app.db.models import Account, AdditionalUsageHistory, UsageHistory
|
|
39
|
+
from app.db.session import relax_commit_durability, sqlite_writer_section
|
|
40
|
+
from app.db.sqlite_utils import sqlite_db_path_from_url
|
|
41
|
+
from app.modules.usage.additional_quota_keys import (
|
|
42
|
+
AdditionalQuotaQueryScope,
|
|
43
|
+
canonicalize_additional_quota_key,
|
|
44
|
+
get_additional_quota_query_scope,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_PRIMARY_WINDOW_LITERAL = literal_column("'primary'")
|
|
48
|
+
NormalizedUsageWindow = Literal["primary", "secondary"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class UsageHistorySnapshot:
|
|
53
|
+
id: int
|
|
54
|
+
account_id: str
|
|
55
|
+
used_percent: float
|
|
56
|
+
recorded_at: datetime
|
|
57
|
+
reset_at: float | None
|
|
58
|
+
window_minutes: int | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class UsageWindowWrite:
|
|
63
|
+
window: str
|
|
64
|
+
used_percent: float
|
|
65
|
+
reset_at: int | None = None
|
|
66
|
+
window_minutes: int | None = None
|
|
67
|
+
credits_has: bool | None = None
|
|
68
|
+
credits_unlimited: bool | None = None
|
|
69
|
+
credits_balance: float | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LiveSnapshotOwnerIdentityRelockError(RuntimeError):
|
|
73
|
+
"""The selected live-snapshot owner's identity changed twice."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _account_snapshot_entries(
|
|
77
|
+
account_id: str,
|
|
78
|
+
windows: Collection[UsageWindowWrite],
|
|
79
|
+
*,
|
|
80
|
+
recorded_at: datetime | None = None,
|
|
81
|
+
) -> list[UsageHistory]:
|
|
82
|
+
captured_at = recorded_at or utcnow()
|
|
83
|
+
return [
|
|
84
|
+
UsageHistory(
|
|
85
|
+
account_id=account_id,
|
|
86
|
+
used_percent=window.used_percent,
|
|
87
|
+
input_tokens=None,
|
|
88
|
+
output_tokens=None,
|
|
89
|
+
window=window.window,
|
|
90
|
+
reset_at=window.reset_at,
|
|
91
|
+
window_minutes=window.window_minutes,
|
|
92
|
+
credits_has=window.credits_has,
|
|
93
|
+
credits_unlimited=window.credits_unlimited,
|
|
94
|
+
credits_balance=window.credits_balance,
|
|
95
|
+
recorded_at=captured_at,
|
|
96
|
+
)
|
|
97
|
+
for window in windows
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class _BulkHistoryCacheMetadata:
|
|
103
|
+
row_count: int
|
|
104
|
+
max_id: int
|
|
105
|
+
content_digest: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class _BulkHistoryCacheEntry:
|
|
110
|
+
since: datetime
|
|
111
|
+
max_id: int
|
|
112
|
+
metadata: _BulkHistoryCacheMetadata
|
|
113
|
+
rows_by_account: dict[str, list[UsageHistorySnapshot]]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_BULK_HISTORY_SQLITE_CACHE: dict[tuple[str, tuple[str, ...], str], _BulkHistoryCacheEntry] = {}
|
|
117
|
+
_BULK_HISTORY_SQLITE_CACHE_LOCK = RLock()
|
|
118
|
+
_EMPTY_BULK_HISTORY_DIGEST = sha256().hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _normalized_sqlite_datetime_text(value) -> str:
|
|
122
|
+
return str(_parse_sqlite_datetime(value))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _BulkHistoryDigestAggregate:
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
self._digest = sha256()
|
|
128
|
+
|
|
129
|
+
def step(
|
|
130
|
+
self,
|
|
131
|
+
row_id: int,
|
|
132
|
+
account_id: str,
|
|
133
|
+
used_percent: float,
|
|
134
|
+
recorded_at: str,
|
|
135
|
+
reset_at: float | None,
|
|
136
|
+
window_minutes: int | None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self._digest.update(str(int(row_id)).encode("utf-8"))
|
|
139
|
+
self._digest.update(b"\x1f")
|
|
140
|
+
account_bytes = str(account_id).encode("utf-8")
|
|
141
|
+
self._digest.update(str(len(account_bytes)).encode("ascii"))
|
|
142
|
+
self._digest.update(b":")
|
|
143
|
+
self._digest.update(account_bytes)
|
|
144
|
+
self._digest.update(b"\x1f")
|
|
145
|
+
self._digest.update(float(used_percent).hex().encode("ascii"))
|
|
146
|
+
self._digest.update(b"\x1f")
|
|
147
|
+
recorded_at_bytes = _normalized_sqlite_datetime_text(recorded_at).encode("utf-8")
|
|
148
|
+
self._digest.update(str(len(recorded_at_bytes)).encode("ascii"))
|
|
149
|
+
self._digest.update(b":")
|
|
150
|
+
self._digest.update(recorded_at_bytes)
|
|
151
|
+
self._digest.update(b"\x1f")
|
|
152
|
+
self._digest.update(b"NULL" if reset_at is None else float(reset_at).hex().encode("ascii"))
|
|
153
|
+
self._digest.update(b"\x1f")
|
|
154
|
+
self._digest.update(b"NULL" if window_minutes is None else str(int(window_minutes)).encode("ascii"))
|
|
155
|
+
self._digest.update(b"\x1e")
|
|
156
|
+
|
|
157
|
+
def finalize(self) -> str:
|
|
158
|
+
return self._digest.hexdigest()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _clear_bulk_history_since_sqlite_cache() -> None:
|
|
162
|
+
with _BULK_HISTORY_SQLITE_CACHE_LOCK:
|
|
163
|
+
_BULK_HISTORY_SQLITE_CACHE.clear()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _bulk_history_cache_key(
|
|
167
|
+
db_path: str,
|
|
168
|
+
account_ids: list[str],
|
|
169
|
+
window: str,
|
|
170
|
+
) -> tuple[str, tuple[str, ...], str]:
|
|
171
|
+
return (db_path, tuple(sorted(account_ids)), window)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _clone_filtered_history(
|
|
175
|
+
grouped: dict[str, list[UsageHistorySnapshot]],
|
|
176
|
+
since: datetime,
|
|
177
|
+
) -> dict[str, list[UsageHistorySnapshot]]:
|
|
178
|
+
filtered_grouped: dict[str, list[UsageHistorySnapshot]] = {}
|
|
179
|
+
for account_id, rows in grouped.items():
|
|
180
|
+
filtered = [row for row in rows if row.recorded_at >= since]
|
|
181
|
+
if filtered:
|
|
182
|
+
filtered_grouped[account_id] = filtered
|
|
183
|
+
return filtered_grouped
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _bulk_history_metadata_from_grouped(
|
|
187
|
+
grouped: dict[str, list[UsageHistorySnapshot]],
|
|
188
|
+
) -> _BulkHistoryCacheMetadata:
|
|
189
|
+
digest = sha256()
|
|
190
|
+
rows = sorted((row for rows in grouped.values() for row in rows), key=lambda row: (row.id, row.account_id))
|
|
191
|
+
for row in rows:
|
|
192
|
+
digest.update(str(int(row.id)).encode("utf-8"))
|
|
193
|
+
digest.update(b"\x1f")
|
|
194
|
+
account_bytes = str(row.account_id).encode("utf-8")
|
|
195
|
+
digest.update(str(len(account_bytes)).encode("ascii"))
|
|
196
|
+
digest.update(b":")
|
|
197
|
+
digest.update(account_bytes)
|
|
198
|
+
digest.update(b"\x1f")
|
|
199
|
+
digest.update(float(row.used_percent).hex().encode("ascii"))
|
|
200
|
+
digest.update(b"\x1f")
|
|
201
|
+
recorded_at_bytes = str(row.recorded_at).encode("utf-8")
|
|
202
|
+
digest.update(str(len(recorded_at_bytes)).encode("ascii"))
|
|
203
|
+
digest.update(b":")
|
|
204
|
+
digest.update(recorded_at_bytes)
|
|
205
|
+
digest.update(b"\x1f")
|
|
206
|
+
digest.update(b"NULL" if row.reset_at is None else float(row.reset_at).hex().encode("ascii"))
|
|
207
|
+
digest.update(b"\x1f")
|
|
208
|
+
digest.update(b"NULL" if row.window_minutes is None else str(int(row.window_minutes)).encode("ascii"))
|
|
209
|
+
digest.update(b"\x1e")
|
|
210
|
+
return _BulkHistoryCacheMetadata(
|
|
211
|
+
row_count=len(rows),
|
|
212
|
+
max_id=max((row.id for row in rows), default=0),
|
|
213
|
+
content_digest=digest.hexdigest(),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _append_grouped_history(
|
|
218
|
+
target: dict[str, list[UsageHistorySnapshot]],
|
|
219
|
+
source: dict[str, list[UsageHistorySnapshot]],
|
|
220
|
+
) -> None:
|
|
221
|
+
for account_id, rows in source.items():
|
|
222
|
+
bucket = target.setdefault(account_id, [])
|
|
223
|
+
bucket.extend(rows)
|
|
224
|
+
bucket.sort(key=lambda row: (row.recorded_at, row.id))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _query_bulk_history_since_sqlite(
|
|
228
|
+
conn: sqlite3.Connection,
|
|
229
|
+
account_ids: list[str],
|
|
230
|
+
window: str,
|
|
231
|
+
since: datetime,
|
|
232
|
+
*,
|
|
233
|
+
after_id: int | None = None,
|
|
234
|
+
) -> dict[str, list[UsageHistorySnapshot]]:
|
|
235
|
+
placeholders = ",".join("?" for _ in account_ids)
|
|
236
|
+
since_param = since.isoformat(sep=" ")
|
|
237
|
+
id_clause = ""
|
|
238
|
+
params: list[object]
|
|
239
|
+
if window == "primary":
|
|
240
|
+
window_clause = "coalesce(window, 'primary') = 'primary'"
|
|
241
|
+
params = [*account_ids, since_param]
|
|
242
|
+
else:
|
|
243
|
+
window_clause = "window = ?"
|
|
244
|
+
params = [*account_ids, window, since_param]
|
|
245
|
+
if after_id is not None:
|
|
246
|
+
id_clause = "and id > ?"
|
|
247
|
+
params.append(after_id)
|
|
248
|
+
sql = f"""
|
|
249
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
250
|
+
from usage_history
|
|
251
|
+
where account_id in ({placeholders})
|
|
252
|
+
and {window_clause}
|
|
253
|
+
and recorded_at >= ?
|
|
254
|
+
{id_clause}
|
|
255
|
+
order by account_id, recorded_at asc
|
|
256
|
+
"""
|
|
257
|
+
grouped: dict[str, list[UsageHistorySnapshot]] = {}
|
|
258
|
+
rows = conn.execute(sql, params)
|
|
259
|
+
for row in rows:
|
|
260
|
+
snapshot = UsageHistorySnapshot(
|
|
261
|
+
id=int(row[0]),
|
|
262
|
+
account_id=str(row[1]),
|
|
263
|
+
used_percent=float(row[2]),
|
|
264
|
+
recorded_at=_parse_sqlite_datetime(row[3]),
|
|
265
|
+
reset_at=float(row[4]) if row[4] is not None else None,
|
|
266
|
+
window_minutes=int(row[5]) if row[5] is not None else None,
|
|
267
|
+
)
|
|
268
|
+
grouped.setdefault(snapshot.account_id, []).append(snapshot)
|
|
269
|
+
return grouped
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _query_bulk_history_metadata_sqlite(
|
|
273
|
+
conn: sqlite3.Connection,
|
|
274
|
+
account_ids: list[str],
|
|
275
|
+
window: str,
|
|
276
|
+
since: datetime,
|
|
277
|
+
*,
|
|
278
|
+
max_id: int | None = None,
|
|
279
|
+
) -> _BulkHistoryCacheMetadata:
|
|
280
|
+
placeholders = ",".join("?" for _ in account_ids)
|
|
281
|
+
since_param = since.isoformat(sep=" ")
|
|
282
|
+
id_clause = ""
|
|
283
|
+
params: list[object]
|
|
284
|
+
if window == "primary":
|
|
285
|
+
window_clause = "coalesce(window, 'primary') = 'primary'"
|
|
286
|
+
params = [*account_ids, since_param]
|
|
287
|
+
else:
|
|
288
|
+
window_clause = "window = ?"
|
|
289
|
+
params = [*account_ids, window, since_param]
|
|
290
|
+
if max_id is not None:
|
|
291
|
+
id_clause = "and id <= ?"
|
|
292
|
+
params.append(max_id)
|
|
293
|
+
conn.create_aggregate("clb_bulk_history_digest", 6, cast(Any, _BulkHistoryDigestAggregate))
|
|
294
|
+
sql = f"""
|
|
295
|
+
select count(*),
|
|
296
|
+
coalesce(max(id), 0),
|
|
297
|
+
coalesce(
|
|
298
|
+
clb_bulk_history_digest(id, account_id, used_percent, recorded_at, reset_at, window_minutes),
|
|
299
|
+
'{_EMPTY_BULK_HISTORY_DIGEST}'
|
|
300
|
+
)
|
|
301
|
+
from (
|
|
302
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
303
|
+
from usage_history
|
|
304
|
+
where account_id in ({placeholders})
|
|
305
|
+
and {window_clause}
|
|
306
|
+
and recorded_at >= ?
|
|
307
|
+
{id_clause}
|
|
308
|
+
order by id asc, account_id asc
|
|
309
|
+
)
|
|
310
|
+
"""
|
|
311
|
+
row = conn.execute(sql, params).fetchone()
|
|
312
|
+
return _BulkHistoryCacheMetadata(
|
|
313
|
+
row_count=int(row[0]),
|
|
314
|
+
max_id=int(row[1]),
|
|
315
|
+
content_digest=str(row[2]),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _normalized_window_expr():
|
|
320
|
+
return func.coalesce(UsageHistory.window, _PRIMARY_WINDOW_LITERAL)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _window_clause(window: str | None):
|
|
324
|
+
if not window or window == "primary":
|
|
325
|
+
return _normalized_window_expr() == "primary"
|
|
326
|
+
return UsageHistory.window == window
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _sqlite_path_from_bind(bind) -> object | None:
|
|
330
|
+
bind_url = getattr(bind, "url", None)
|
|
331
|
+
if bind_url is not None:
|
|
332
|
+
return sqlite_db_path_from_url(str(bind_url))
|
|
333
|
+
return sqlite_db_path_from_url(get_settings().database_url)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_sqlite_datetime(value) -> datetime:
|
|
337
|
+
if isinstance(value, datetime):
|
|
338
|
+
return value
|
|
339
|
+
return datetime.fromisoformat(str(value))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _usage_history_from_sqlite_row(row) -> UsageHistory:
|
|
343
|
+
return UsageHistory(
|
|
344
|
+
id=int(row[0]),
|
|
345
|
+
account_id=str(row[1]),
|
|
346
|
+
recorded_at=_parse_sqlite_datetime(row[2]),
|
|
347
|
+
window=row[3],
|
|
348
|
+
used_percent=float(row[4]),
|
|
349
|
+
input_tokens=int(row[5]) if row[5] is not None else None,
|
|
350
|
+
output_tokens=int(row[6]) if row[6] is not None else None,
|
|
351
|
+
reset_at=int(row[7]) if row[7] is not None else None,
|
|
352
|
+
window_minutes=int(row[8]) if row[8] is not None else None,
|
|
353
|
+
credits_has=bool(row[9]) if row[9] is not None else None,
|
|
354
|
+
credits_unlimited=bool(row[10]) if row[10] is not None else None,
|
|
355
|
+
credits_balance=float(row[11]) if row[11] is not None else None,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _additional_usage_history_from_sqlite_row(row) -> AdditionalUsageHistory:
|
|
360
|
+
return AdditionalUsageHistory(
|
|
361
|
+
id=int(row[0]),
|
|
362
|
+
account_id=str(row[1]),
|
|
363
|
+
quota_key=str(row[2]),
|
|
364
|
+
limit_name=str(row[3]),
|
|
365
|
+
metered_feature=str(row[4]),
|
|
366
|
+
window=str(row[5]),
|
|
367
|
+
used_percent=float(row[6]),
|
|
368
|
+
reset_at=int(row[7]) if row[7] is not None else None,
|
|
369
|
+
window_minutes=int(row[8]) if row[8] is not None else None,
|
|
370
|
+
recorded_at=_parse_sqlite_datetime(row[9]),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _latest_by_account_sqlite(
|
|
375
|
+
db_path: str,
|
|
376
|
+
window: str | None,
|
|
377
|
+
account_ids: list[str] | None,
|
|
378
|
+
) -> dict[str, UsageHistory]:
|
|
379
|
+
if account_ids is None:
|
|
380
|
+
account_sql = "select id from accounts"
|
|
381
|
+
account_params: list[object] = []
|
|
382
|
+
elif not account_ids:
|
|
383
|
+
return {}
|
|
384
|
+
else:
|
|
385
|
+
placeholders = ",".join("?" for _ in account_ids)
|
|
386
|
+
account_sql = f"select id from accounts where id in ({placeholders})"
|
|
387
|
+
account_params = list(account_ids)
|
|
388
|
+
|
|
389
|
+
if not window or window == "primary":
|
|
390
|
+
window_clause = "coalesce(window, 'primary') = 'primary'"
|
|
391
|
+
window_params: list[object] = []
|
|
392
|
+
else:
|
|
393
|
+
window_clause = "window = ?"
|
|
394
|
+
window_params = [window]
|
|
395
|
+
latest_sql = f"""
|
|
396
|
+
select id, account_id, recorded_at, window, used_percent,
|
|
397
|
+
input_tokens, output_tokens, reset_at, window_minutes,
|
|
398
|
+
credits_has, credits_unlimited, credits_balance
|
|
399
|
+
from usage_history
|
|
400
|
+
where account_id = ?
|
|
401
|
+
and {window_clause}
|
|
402
|
+
order by recorded_at desc, id desc
|
|
403
|
+
limit 1
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
latest: dict[str, UsageHistory] = {}
|
|
407
|
+
with closing(sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)) as conn:
|
|
408
|
+
conn.execute("PRAGMA query_only=ON")
|
|
409
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
410
|
+
accounts = [str(row[0]) for row in conn.execute(account_sql, account_params)]
|
|
411
|
+
for account_id in accounts:
|
|
412
|
+
row = conn.execute(latest_sql, [account_id, *window_params]).fetchone()
|
|
413
|
+
if row is not None:
|
|
414
|
+
entry = _usage_history_from_sqlite_row(row)
|
|
415
|
+
latest[entry.account_id] = entry
|
|
416
|
+
return latest
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _additional_scope_sqlite_clause(scope: AdditionalQuotaQueryScope) -> tuple[str, list[object]]:
|
|
420
|
+
quota_values = tuple(scope.quota_key_match_values or {scope.quota_key})
|
|
421
|
+
clauses = [f"quota_key in ({','.join('?' for _ in quota_values)})"]
|
|
422
|
+
params: list[object] = list(quota_values)
|
|
423
|
+
if scope.limit_name_match_values:
|
|
424
|
+
clauses.append(f"lower(limit_name) in ({','.join('?' for _ in scope.limit_name_match_values)})")
|
|
425
|
+
params.extend(scope.limit_name_match_values)
|
|
426
|
+
if scope.metered_feature_match_values:
|
|
427
|
+
clauses.append(f"lower(metered_feature) in ({','.join('?' for _ in scope.metered_feature_match_values)})")
|
|
428
|
+
params.extend(scope.metered_feature_match_values)
|
|
429
|
+
return f"({' or '.join(clauses)})", params
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _bulk_history_since_sqlite(
|
|
433
|
+
db_path: str,
|
|
434
|
+
account_ids: list[str],
|
|
435
|
+
window: str,
|
|
436
|
+
since: datetime,
|
|
437
|
+
) -> dict[str, list[UsageHistorySnapshot]]:
|
|
438
|
+
cache_key = _bulk_history_cache_key(db_path, account_ids, window)
|
|
439
|
+
with closing(sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)) as conn:
|
|
440
|
+
conn.execute("PRAGMA query_only=ON")
|
|
441
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
442
|
+
with _BULK_HISTORY_SQLITE_CACHE_LOCK:
|
|
443
|
+
cached = _BULK_HISTORY_SQLITE_CACHE.get(cache_key)
|
|
444
|
+
if cached is not None and cached.since <= since:
|
|
445
|
+
metadata = _query_bulk_history_metadata_sqlite(
|
|
446
|
+
conn,
|
|
447
|
+
account_ids,
|
|
448
|
+
window,
|
|
449
|
+
cached.since,
|
|
450
|
+
max_id=cached.max_id,
|
|
451
|
+
)
|
|
452
|
+
if metadata != cached.metadata:
|
|
453
|
+
grouped = _query_bulk_history_since_sqlite(conn, account_ids, window, cached.since)
|
|
454
|
+
cached.metadata = _bulk_history_metadata_from_grouped(grouped)
|
|
455
|
+
cached.max_id = cached.metadata.max_id
|
|
456
|
+
cached.rows_by_account = grouped
|
|
457
|
+
return _clone_filtered_history(grouped, since)
|
|
458
|
+
|
|
459
|
+
new_rows = _query_bulk_history_since_sqlite(
|
|
460
|
+
conn,
|
|
461
|
+
account_ids,
|
|
462
|
+
window,
|
|
463
|
+
cached.since,
|
|
464
|
+
after_id=cached.max_id,
|
|
465
|
+
)
|
|
466
|
+
if new_rows:
|
|
467
|
+
_append_grouped_history(cached.rows_by_account, new_rows)
|
|
468
|
+
cached.metadata = _bulk_history_metadata_from_grouped(cached.rows_by_account)
|
|
469
|
+
cached.max_id = cached.metadata.max_id
|
|
470
|
+
return _clone_filtered_history(cached.rows_by_account, since)
|
|
471
|
+
|
|
472
|
+
grouped = _query_bulk_history_since_sqlite(conn, account_ids, window, since)
|
|
473
|
+
metadata = _bulk_history_metadata_from_grouped(grouped)
|
|
474
|
+
_BULK_HISTORY_SQLITE_CACHE[cache_key] = _BulkHistoryCacheEntry(
|
|
475
|
+
since=since,
|
|
476
|
+
max_id=metadata.max_id,
|
|
477
|
+
metadata=metadata,
|
|
478
|
+
rows_by_account=grouped,
|
|
479
|
+
)
|
|
480
|
+
return _clone_filtered_history(grouped, since)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _bulk_history_since_capped_sqlite(
|
|
484
|
+
db_path: str,
|
|
485
|
+
account_ids: list[str],
|
|
486
|
+
window: str,
|
|
487
|
+
since: datetime,
|
|
488
|
+
*,
|
|
489
|
+
cutoffs: dict[str, datetime] | None = None,
|
|
490
|
+
per_account_row_cap: int,
|
|
491
|
+
uncapped_recent_floor: datetime | None = None,
|
|
492
|
+
) -> dict[str, list[UsageHistorySnapshot]]:
|
|
493
|
+
"""Per-account newest-first capped history fetch for SQLite.
|
|
494
|
+
|
|
495
|
+
Mirrors _bulk_history_since_capped_postgresql using SQLite's composite
|
|
496
|
+
indexes idx_usage_window_account_latest and idx_usage_window_account_time_covering.
|
|
497
|
+
|
|
498
|
+
For each account, executes targeted index seeks:
|
|
499
|
+
- If uncapped_recent_floor is None: probes at most per_account_row_cap newest
|
|
500
|
+
rows >= cutoff.
|
|
501
|
+
- If uncapped_recent_floor is provided: unions the capped tail [cutoff, uncapped_floor)
|
|
502
|
+
bounded by per_account_row_cap with all uncapped rows >= uncapped_floor.
|
|
503
|
+
"""
|
|
504
|
+
capped_tail_limit = max(0, per_account_row_cap)
|
|
505
|
+
if not account_ids:
|
|
506
|
+
return {}
|
|
507
|
+
|
|
508
|
+
if window == "primary":
|
|
509
|
+
window_clause = "coalesce(window, 'primary') = 'primary'"
|
|
510
|
+
window_params: list[object] = []
|
|
511
|
+
else:
|
|
512
|
+
window_clause = "window = ?"
|
|
513
|
+
window_params = [window]
|
|
514
|
+
|
|
515
|
+
capped_only_sql = f"""
|
|
516
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
517
|
+
from usage_history
|
|
518
|
+
where account_id = ?
|
|
519
|
+
and {window_clause}
|
|
520
|
+
and recorded_at >= ?
|
|
521
|
+
order by recorded_at desc, id desc
|
|
522
|
+
limit ?
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
compound_floor_sql = f"""
|
|
526
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
527
|
+
from (
|
|
528
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
529
|
+
from usage_history
|
|
530
|
+
where account_id = ?
|
|
531
|
+
and {window_clause}
|
|
532
|
+
and recorded_at >= ?
|
|
533
|
+
and recorded_at < ?
|
|
534
|
+
order by recorded_at desc, id desc
|
|
535
|
+
limit ?
|
|
536
|
+
)
|
|
537
|
+
union all
|
|
538
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
539
|
+
from usage_history
|
|
540
|
+
where account_id = ?
|
|
541
|
+
and {window_clause}
|
|
542
|
+
and recorded_at >= ?
|
|
543
|
+
"""
|
|
544
|
+
|
|
545
|
+
all_recent_sql = f"""
|
|
546
|
+
select id, account_id, used_percent, recorded_at, reset_at, window_minutes
|
|
547
|
+
from usage_history
|
|
548
|
+
where account_id = ?
|
|
549
|
+
and {window_clause}
|
|
550
|
+
and recorded_at >= ?
|
|
551
|
+
order by recorded_at asc, id asc
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
grouped: dict[str, list[UsageHistorySnapshot]] = {}
|
|
555
|
+
|
|
556
|
+
with closing(sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)) as conn:
|
|
557
|
+
conn.execute("PRAGMA query_only=ON")
|
|
558
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
559
|
+
|
|
560
|
+
for account_id in account_ids:
|
|
561
|
+
cutoff = max(cutoffs.get(account_id, since), since) if cutoffs else since
|
|
562
|
+
cutoff_param = cutoff.isoformat(sep=" ")
|
|
563
|
+
|
|
564
|
+
if uncapped_recent_floor is None:
|
|
565
|
+
query = capped_only_sql
|
|
566
|
+
params: list[object] = [account_id, *window_params, cutoff_param, capped_tail_limit]
|
|
567
|
+
else:
|
|
568
|
+
uncapped_floor = max(cutoff, uncapped_recent_floor)
|
|
569
|
+
if uncapped_floor <= cutoff:
|
|
570
|
+
# Floor covers the entire lookback; run uncapped query
|
|
571
|
+
query = all_recent_sql
|
|
572
|
+
params = [account_id, *window_params, cutoff_param]
|
|
573
|
+
else:
|
|
574
|
+
floor_param = uncapped_floor.isoformat(sep=" ")
|
|
575
|
+
query = compound_floor_sql
|
|
576
|
+
params = [
|
|
577
|
+
account_id,
|
|
578
|
+
*window_params,
|
|
579
|
+
cutoff_param,
|
|
580
|
+
floor_param,
|
|
581
|
+
capped_tail_limit,
|
|
582
|
+
account_id,
|
|
583
|
+
*window_params,
|
|
584
|
+
floor_param,
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
rows = conn.execute(query, params).fetchall()
|
|
588
|
+
if not rows:
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
snapshots = [
|
|
592
|
+
UsageHistorySnapshot(
|
|
593
|
+
id=int(r[0]),
|
|
594
|
+
account_id=str(r[1]),
|
|
595
|
+
used_percent=float(r[2]),
|
|
596
|
+
recorded_at=_parse_sqlite_datetime(r[3]),
|
|
597
|
+
reset_at=float(r[4]) if r[4] is not None else None,
|
|
598
|
+
window_minutes=int(r[5]) if r[5] is not None else None,
|
|
599
|
+
)
|
|
600
|
+
for r in rows
|
|
601
|
+
]
|
|
602
|
+
# Order oldest-first by (recorded_at, id) matching PostgreSQL contract
|
|
603
|
+
snapshots.sort(key=lambda s: (s.recorded_at, s.id))
|
|
604
|
+
grouped[account_id] = snapshots
|
|
605
|
+
|
|
606
|
+
return grouped
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _resolve_additional_quota_key(
|
|
610
|
+
*,
|
|
611
|
+
quota_key: str | None = None,
|
|
612
|
+
limit_name: str | None = None,
|
|
613
|
+
metered_feature: str | None = None,
|
|
614
|
+
) -> str | None:
|
|
615
|
+
candidate_limit_name = quota_key if quota_key is not None else limit_name
|
|
616
|
+
if candidate_limit_name is None and metered_feature is None:
|
|
617
|
+
return None
|
|
618
|
+
return canonicalize_additional_quota_key(
|
|
619
|
+
quota_key=quota_key,
|
|
620
|
+
limit_name=candidate_limit_name,
|
|
621
|
+
metered_feature=metered_feature,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _resolve_additional_quota_query_scope(
|
|
626
|
+
*,
|
|
627
|
+
quota_key: str | None = None,
|
|
628
|
+
limit_name: str | None = None,
|
|
629
|
+
metered_feature: str | None = None,
|
|
630
|
+
) -> AdditionalQuotaQueryScope | None:
|
|
631
|
+
return get_additional_quota_query_scope(
|
|
632
|
+
quota_key=quota_key,
|
|
633
|
+
limit_name=limit_name,
|
|
634
|
+
metered_feature=metered_feature,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _additional_quota_match_clause(scope: AdditionalQuotaQueryScope, *, canonical_only: bool = False):
|
|
639
|
+
clauses = [AdditionalUsageHistory.quota_key.in_(tuple(scope.quota_key_match_values or {scope.quota_key}))]
|
|
640
|
+
if canonical_only:
|
|
641
|
+
return clauses[0]
|
|
642
|
+
alias_clause = _additional_quota_alias_match_clause(scope)
|
|
643
|
+
if alias_clause is not None:
|
|
644
|
+
clauses.append(alias_clause)
|
|
645
|
+
return or_(*clauses)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _additional_quota_alias_match_clause(scope: AdditionalQuotaQueryScope):
|
|
649
|
+
clauses = []
|
|
650
|
+
if scope.limit_name_match_values:
|
|
651
|
+
clauses.append(func.lower(AdditionalUsageHistory.limit_name).in_(tuple(scope.limit_name_match_values)))
|
|
652
|
+
if scope.metered_feature_match_values:
|
|
653
|
+
clauses.append(
|
|
654
|
+
func.lower(AdditionalUsageHistory.metered_feature).in_(tuple(scope.metered_feature_match_values))
|
|
655
|
+
)
|
|
656
|
+
if not clauses:
|
|
657
|
+
return None
|
|
658
|
+
return or_(*clauses)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _newer_additional_usage_entry(
|
|
662
|
+
current: AdditionalUsageHistory | None,
|
|
663
|
+
candidate: AdditionalUsageHistory,
|
|
664
|
+
) -> AdditionalUsageHistory:
|
|
665
|
+
if current is None:
|
|
666
|
+
return candidate
|
|
667
|
+
current_key = (
|
|
668
|
+
current.recorded_at,
|
|
669
|
+
current.used_percent,
|
|
670
|
+
current.id,
|
|
671
|
+
)
|
|
672
|
+
candidate_key = (
|
|
673
|
+
candidate.recorded_at,
|
|
674
|
+
candidate.used_percent,
|
|
675
|
+
candidate.id,
|
|
676
|
+
)
|
|
677
|
+
return candidate if candidate_key > current_key else current
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _merge_latest_additional_usage_entries(
|
|
681
|
+
entries: dict[str, AdditionalUsageHistory],
|
|
682
|
+
candidates: Collection[AdditionalUsageHistory],
|
|
683
|
+
) -> None:
|
|
684
|
+
for candidate in candidates:
|
|
685
|
+
entries[candidate.account_id] = _newer_additional_usage_entry(
|
|
686
|
+
entries.get(candidate.account_id),
|
|
687
|
+
candidate,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
class UsageRepository:
|
|
692
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
693
|
+
self._session = session
|
|
694
|
+
|
|
695
|
+
async def latest_entry_for_account(
|
|
696
|
+
self,
|
|
697
|
+
account_id: str,
|
|
698
|
+
*,
|
|
699
|
+
window: str | None = None,
|
|
700
|
+
) -> UsageHistory | None:
|
|
701
|
+
stmt = (
|
|
702
|
+
select(UsageHistory)
|
|
703
|
+
.where(UsageHistory.account_id == account_id)
|
|
704
|
+
.where(_window_clause(window))
|
|
705
|
+
.order_by(UsageHistory.recorded_at.desc(), UsageHistory.id.desc())
|
|
706
|
+
.limit(1)
|
|
707
|
+
)
|
|
708
|
+
result = await self._session.execute(stmt)
|
|
709
|
+
return result.scalar_one_or_none()
|
|
710
|
+
|
|
711
|
+
async def add_entry(
|
|
712
|
+
self,
|
|
713
|
+
account_id: str,
|
|
714
|
+
used_percent: float,
|
|
715
|
+
input_tokens: int | None = None,
|
|
716
|
+
output_tokens: int | None = None,
|
|
717
|
+
recorded_at: datetime | None = None,
|
|
718
|
+
window: str | None = None,
|
|
719
|
+
reset_at: int | None = None,
|
|
720
|
+
window_minutes: int | None = None,
|
|
721
|
+
credits_has: bool | None = None,
|
|
722
|
+
credits_unlimited: bool | None = None,
|
|
723
|
+
credits_balance: float | None = None,
|
|
724
|
+
) -> UsageHistory:
|
|
725
|
+
entry = UsageHistory(
|
|
726
|
+
account_id=account_id,
|
|
727
|
+
used_percent=used_percent,
|
|
728
|
+
input_tokens=input_tokens,
|
|
729
|
+
output_tokens=output_tokens,
|
|
730
|
+
window=window,
|
|
731
|
+
reset_at=reset_at,
|
|
732
|
+
window_minutes=window_minutes,
|
|
733
|
+
credits_has=credits_has,
|
|
734
|
+
credits_unlimited=credits_unlimited,
|
|
735
|
+
credits_balance=credits_balance,
|
|
736
|
+
recorded_at=recorded_at or utcnow(),
|
|
737
|
+
)
|
|
738
|
+
async with sqlite_writer_section():
|
|
739
|
+
# Telemetry write: this transaction only appends usage-history
|
|
740
|
+
# rows, so its commit may skip the synchronous WAL flush.
|
|
741
|
+
await relax_commit_durability(self._session)
|
|
742
|
+
self._session.add(entry)
|
|
743
|
+
await self._session.commit()
|
|
744
|
+
await self._session.refresh(entry)
|
|
745
|
+
return entry
|
|
746
|
+
|
|
747
|
+
async def add_account_snapshot(
|
|
748
|
+
self,
|
|
749
|
+
account_id: str,
|
|
750
|
+
windows: Collection[UsageWindowWrite],
|
|
751
|
+
*,
|
|
752
|
+
recorded_at: datetime | None = None,
|
|
753
|
+
) -> list[UsageHistory]:
|
|
754
|
+
"""Persist one account's standard usage windows atomically."""
|
|
755
|
+
if not windows:
|
|
756
|
+
return []
|
|
757
|
+
entries = _account_snapshot_entries(account_id, windows, recorded_at=recorded_at)
|
|
758
|
+
try:
|
|
759
|
+
async with sqlite_writer_section():
|
|
760
|
+
# Telemetry write: this transaction only appends usage-history
|
|
761
|
+
# rows, so its commit may skip the synchronous WAL flush.
|
|
762
|
+
await relax_commit_durability(self._session)
|
|
763
|
+
self._session.add_all(entries)
|
|
764
|
+
await self._session.commit()
|
|
765
|
+
except BaseException:
|
|
766
|
+
await self._session.rollback()
|
|
767
|
+
raise
|
|
768
|
+
return entries
|
|
769
|
+
|
|
770
|
+
async def _resolve_postgresql_live_snapshot_owner(
|
|
771
|
+
self,
|
|
772
|
+
account_id: str | None,
|
|
773
|
+
chatgpt_account_id: str | None,
|
|
774
|
+
) -> str | None:
|
|
775
|
+
locked_identities = (chatgpt_account_id,)
|
|
776
|
+
fallback_identity = chatgpt_account_id
|
|
777
|
+
relocked = False
|
|
778
|
+
|
|
779
|
+
while True:
|
|
780
|
+
await lock_postgresql_account_identities(self._session, locked_identities)
|
|
781
|
+
locked_identity_values = frozenset(identity for identity in locked_identities if identity)
|
|
782
|
+
identity_to_relock: str | None = None
|
|
783
|
+
|
|
784
|
+
if account_id is not None:
|
|
785
|
+
# Read before taking the row lock so MVCC preserves the
|
|
786
|
+
# current recovery identity even when its writer has already
|
|
787
|
+
# deleted the local row but not committed yet.
|
|
788
|
+
observed = (
|
|
789
|
+
await self._session.execute(
|
|
790
|
+
select(Account.id, Account.chatgpt_account_id).where(Account.id == account_id)
|
|
791
|
+
)
|
|
792
|
+
).one_or_none()
|
|
793
|
+
if observed is not None:
|
|
794
|
+
observed_identity = observed.chatgpt_account_id
|
|
795
|
+
if observed_identity and observed_identity not in locked_identity_values:
|
|
796
|
+
identity_to_relock = observed_identity
|
|
797
|
+
else:
|
|
798
|
+
locked = (
|
|
799
|
+
await self._session.execute(
|
|
800
|
+
select(Account.id, Account.chatgpt_account_id)
|
|
801
|
+
.where(Account.id == account_id)
|
|
802
|
+
.with_for_update(key_share=True)
|
|
803
|
+
)
|
|
804
|
+
).one_or_none()
|
|
805
|
+
if locked is not None:
|
|
806
|
+
if locked.chatgpt_account_id and locked.chatgpt_account_id not in locked_identity_values:
|
|
807
|
+
identity_to_relock = locked.chatgpt_account_id
|
|
808
|
+
else:
|
|
809
|
+
return locked.id
|
|
810
|
+
|
|
811
|
+
if identity_to_relock is not None:
|
|
812
|
+
if relocked:
|
|
813
|
+
raise LiveSnapshotOwnerIdentityRelockError(
|
|
814
|
+
"Live snapshot owner identity changed during PostgreSQL relock"
|
|
815
|
+
)
|
|
816
|
+
# Release the first lock before adding another identity; the
|
|
817
|
+
# shared helper can then reacquire the full set in canonical
|
|
818
|
+
# order without inverting an account writer's lock order.
|
|
819
|
+
await self._session.rollback()
|
|
820
|
+
fallback_identity = identity_to_relock
|
|
821
|
+
locked_identities = (chatgpt_account_id, identity_to_relock)
|
|
822
|
+
relocked = True
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
if fallback_identity:
|
|
826
|
+
upstream_stmt = (
|
|
827
|
+
select(Account.id)
|
|
828
|
+
.where(Account.chatgpt_account_id == fallback_identity)
|
|
829
|
+
.with_for_update(key_share=True)
|
|
830
|
+
)
|
|
831
|
+
matches = list((await self._session.execute(upstream_stmt)).scalars().all())
|
|
832
|
+
if len(matches) == 1:
|
|
833
|
+
return matches[0]
|
|
834
|
+
return None
|
|
835
|
+
|
|
836
|
+
async def settle_live_account_snapshot(
|
|
837
|
+
self,
|
|
838
|
+
*,
|
|
839
|
+
account_id: str | None,
|
|
840
|
+
chatgpt_account_id: str | None,
|
|
841
|
+
windows: Collection[UsageWindowWrite],
|
|
842
|
+
should_skip: Callable[[str], bool],
|
|
843
|
+
) -> str | None:
|
|
844
|
+
"""Resolve a live snapshot owner and atomically persist its windows."""
|
|
845
|
+
if not windows:
|
|
846
|
+
return None
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
async with sqlite_writer_section():
|
|
850
|
+
bind = self._session.get_bind()
|
|
851
|
+
dialect_name = bind.dialect.name if bind is not None else "sqlite"
|
|
852
|
+
if dialect_name == "sqlite":
|
|
853
|
+
# Acquire SQLite's database-wide writer slot before owner
|
|
854
|
+
# lookup. Consolidation then commits before this lookup or
|
|
855
|
+
# waits until the snapshot commit, so the chosen FK owner
|
|
856
|
+
# cannot disappear between SELECT and INSERT.
|
|
857
|
+
await self._session.execute(text("BEGIN IMMEDIATE"))
|
|
858
|
+
resolved_account_id = None
|
|
859
|
+
if account_id is not None:
|
|
860
|
+
resolved_account_id = await self._session.scalar(
|
|
861
|
+
select(Account.id).where(Account.id == account_id)
|
|
862
|
+
)
|
|
863
|
+
if resolved_account_id is None and chatgpt_account_id:
|
|
864
|
+
matches = list(
|
|
865
|
+
(
|
|
866
|
+
await self._session.execute(
|
|
867
|
+
select(Account.id).where(Account.chatgpt_account_id == chatgpt_account_id)
|
|
868
|
+
)
|
|
869
|
+
)
|
|
870
|
+
.scalars()
|
|
871
|
+
.all()
|
|
872
|
+
)
|
|
873
|
+
if len(matches) == 1:
|
|
874
|
+
resolved_account_id = matches[0]
|
|
875
|
+
else:
|
|
876
|
+
resolved_account_id = await self._resolve_postgresql_live_snapshot_owner(
|
|
877
|
+
account_id,
|
|
878
|
+
chatgpt_account_id,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
if resolved_account_id is None or should_skip(resolved_account_id):
|
|
882
|
+
await self._session.rollback()
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
entries = _account_snapshot_entries(resolved_account_id, windows)
|
|
886
|
+
# Telemetry write: this transaction only locks the owner and
|
|
887
|
+
# appends usage-history rows, so it may skip synchronous WAL
|
|
888
|
+
# flush just like add_account_snapshot().
|
|
889
|
+
await relax_commit_durability(self._session)
|
|
890
|
+
self._session.add_all(entries)
|
|
891
|
+
await self._session.commit()
|
|
892
|
+
except BaseException:
|
|
893
|
+
await self._session.rollback()
|
|
894
|
+
raise
|
|
895
|
+
return resolved_account_id
|
|
896
|
+
|
|
897
|
+
async def aggregate_since(
|
|
898
|
+
self,
|
|
899
|
+
since: datetime,
|
|
900
|
+
window: str | None = None,
|
|
901
|
+
) -> list[UsageAggregateRow]:
|
|
902
|
+
conditions = [UsageHistory.recorded_at >= since]
|
|
903
|
+
if window:
|
|
904
|
+
conditions.append(_window_clause(window))
|
|
905
|
+
stmt = (
|
|
906
|
+
select(
|
|
907
|
+
UsageHistory.account_id,
|
|
908
|
+
func.avg(UsageHistory.used_percent).label("used_percent_avg"),
|
|
909
|
+
func.sum(UsageHistory.input_tokens).label("input_tokens_sum"),
|
|
910
|
+
func.sum(UsageHistory.output_tokens).label("output_tokens_sum"),
|
|
911
|
+
func.count(UsageHistory.id).label("samples"),
|
|
912
|
+
func.max(UsageHistory.recorded_at).label("last_recorded_at"),
|
|
913
|
+
func.max(UsageHistory.reset_at).label("reset_at_max"),
|
|
914
|
+
func.max(UsageHistory.window_minutes).label("window_minutes_max"),
|
|
915
|
+
)
|
|
916
|
+
.where(*conditions)
|
|
917
|
+
.group_by(UsageHistory.account_id)
|
|
918
|
+
)
|
|
919
|
+
result = await self._session.execute(stmt)
|
|
920
|
+
rows = result.all()
|
|
921
|
+
return [
|
|
922
|
+
UsageAggregateRow(
|
|
923
|
+
account_id=row.account_id,
|
|
924
|
+
used_percent_avg=float(row.used_percent_avg) if row.used_percent_avg is not None else None,
|
|
925
|
+
input_tokens_sum=int(row.input_tokens_sum) if row.input_tokens_sum is not None else None,
|
|
926
|
+
output_tokens_sum=int(row.output_tokens_sum) if row.output_tokens_sum is not None else None,
|
|
927
|
+
samples=int(row.samples),
|
|
928
|
+
last_recorded_at=row.last_recorded_at,
|
|
929
|
+
reset_at_max=int(row.reset_at_max) if row.reset_at_max is not None else None,
|
|
930
|
+
window_minutes_max=int(row.window_minutes_max) if row.window_minutes_max is not None else None,
|
|
931
|
+
)
|
|
932
|
+
for row in rows
|
|
933
|
+
]
|
|
934
|
+
|
|
935
|
+
async def positive_used_percent_deltas_by_account(
|
|
936
|
+
self,
|
|
937
|
+
account_windows: Mapping[str, NormalizedUsageWindow],
|
|
938
|
+
*,
|
|
939
|
+
since: datetime,
|
|
940
|
+
until: datetime,
|
|
941
|
+
) -> dict[str, float]:
|
|
942
|
+
if not account_windows:
|
|
943
|
+
return {}
|
|
944
|
+
account_window_pairs = tuple(account_windows.items())
|
|
945
|
+
samples = (
|
|
946
|
+
select(
|
|
947
|
+
UsageHistory.account_id.label("account_id"),
|
|
948
|
+
UsageHistory.used_percent.label("used_percent"),
|
|
949
|
+
func.lag(UsageHistory.used_percent)
|
|
950
|
+
.over(
|
|
951
|
+
partition_by=UsageHistory.account_id,
|
|
952
|
+
order_by=(UsageHistory.recorded_at, UsageHistory.id),
|
|
953
|
+
)
|
|
954
|
+
.label("previous_used_percent"),
|
|
955
|
+
)
|
|
956
|
+
.where(
|
|
957
|
+
tuple_(UsageHistory.account_id, _normalized_window_expr()).in_(account_window_pairs),
|
|
958
|
+
UsageHistory.recorded_at >= since,
|
|
959
|
+
UsageHistory.recorded_at <= until,
|
|
960
|
+
)
|
|
961
|
+
.subquery("weekly_demand_samples")
|
|
962
|
+
)
|
|
963
|
+
delta = samples.c.used_percent - samples.c.previous_used_percent
|
|
964
|
+
statement = select(
|
|
965
|
+
samples.c.account_id,
|
|
966
|
+
func.coalesce(
|
|
967
|
+
func.sum(case((delta > 0, delta), else_=0.0)),
|
|
968
|
+
0.0,
|
|
969
|
+
).label("positive_delta"),
|
|
970
|
+
).group_by(samples.c.account_id)
|
|
971
|
+
rows = (await self._session.execute(statement)).all()
|
|
972
|
+
return {str(row.account_id): float(row.positive_delta) for row in rows}
|
|
973
|
+
|
|
974
|
+
async def latest_by_account(
|
|
975
|
+
self,
|
|
976
|
+
window: str | None = None,
|
|
977
|
+
*,
|
|
978
|
+
account_ids: Collection[str] | None = None,
|
|
979
|
+
) -> dict[str, UsageHistory]:
|
|
980
|
+
conditions = _window_clause(window)
|
|
981
|
+
if account_ids is not None and not account_ids:
|
|
982
|
+
return {}
|
|
983
|
+
if account_ids is not None:
|
|
984
|
+
conditions = and_(conditions, UsageHistory.account_id.in_(account_ids))
|
|
985
|
+
bind = self._session.get_bind()
|
|
986
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
987
|
+
sqlite_path = _sqlite_path_from_bind(bind) if dialect == "sqlite" else None
|
|
988
|
+
if sqlite_path is not None:
|
|
989
|
+
return await to_thread.run_sync(
|
|
990
|
+
_latest_by_account_sqlite,
|
|
991
|
+
str(sqlite_path),
|
|
992
|
+
window,
|
|
993
|
+
list(account_ids) if account_ids is not None else None,
|
|
994
|
+
)
|
|
995
|
+
if dialect == "postgresql":
|
|
996
|
+
acct_stmt = select(Account.id)
|
|
997
|
+
if account_ids is not None:
|
|
998
|
+
acct_stmt = acct_stmt.where(Account.id.in_(account_ids))
|
|
999
|
+
acct_subq = acct_stmt.subquery("accts")
|
|
1000
|
+
lateral = (
|
|
1001
|
+
select(UsageHistory.id)
|
|
1002
|
+
.where(
|
|
1003
|
+
conditions,
|
|
1004
|
+
UsageHistory.account_id == acct_subq.c.id,
|
|
1005
|
+
)
|
|
1006
|
+
.order_by(UsageHistory.recorded_at.desc(), UsageHistory.id.desc())
|
|
1007
|
+
.limit(1)
|
|
1008
|
+
.correlate(acct_subq)
|
|
1009
|
+
.lateral("latest")
|
|
1010
|
+
)
|
|
1011
|
+
id_query = (
|
|
1012
|
+
select(lateral.c.id).select_from(acct_subq.outerjoin(lateral, true())).where(lateral.c.id.is_not(None))
|
|
1013
|
+
)
|
|
1014
|
+
stmt = select(UsageHistory).where(UsageHistory.id.in_(id_query))
|
|
1015
|
+
result = await self._session.execute(stmt)
|
|
1016
|
+
return {entry.account_id: entry for entry in result.scalars().all()}
|
|
1017
|
+
|
|
1018
|
+
acct_stmt = select(Account.id)
|
|
1019
|
+
if account_ids is not None:
|
|
1020
|
+
acct_stmt = acct_stmt.where(Account.id.in_(account_ids))
|
|
1021
|
+
acct_subq = acct_stmt.subquery("accts")
|
|
1022
|
+
latest_id = (
|
|
1023
|
+
select(UsageHistory.id)
|
|
1024
|
+
.where(
|
|
1025
|
+
conditions,
|
|
1026
|
+
UsageHistory.account_id == acct_subq.c.id,
|
|
1027
|
+
)
|
|
1028
|
+
.order_by(UsageHistory.recorded_at.desc(), UsageHistory.id.desc())
|
|
1029
|
+
.limit(1)
|
|
1030
|
+
.correlate(acct_subq)
|
|
1031
|
+
.scalar_subquery()
|
|
1032
|
+
)
|
|
1033
|
+
id_rows = select(latest_id.label("usage_id")).select_from(acct_subq).subquery("latest_ids")
|
|
1034
|
+
stmt = select(UsageHistory).join(id_rows, UsageHistory.id == id_rows.c.usage_id)
|
|
1035
|
+
result = await self._session.execute(stmt)
|
|
1036
|
+
return {entry.account_id: entry for entry in result.scalars().all()}
|
|
1037
|
+
|
|
1038
|
+
async def history_since(
|
|
1039
|
+
self,
|
|
1040
|
+
account_id: str,
|
|
1041
|
+
window: str,
|
|
1042
|
+
since: datetime,
|
|
1043
|
+
) -> list[UsageHistory]:
|
|
1044
|
+
stmt = (
|
|
1045
|
+
select(UsageHistory)
|
|
1046
|
+
.where(
|
|
1047
|
+
UsageHistory.account_id == account_id,
|
|
1048
|
+
_window_clause(window),
|
|
1049
|
+
UsageHistory.recorded_at >= since,
|
|
1050
|
+
)
|
|
1051
|
+
.order_by(UsageHistory.recorded_at.asc(), UsageHistory.id.asc())
|
|
1052
|
+
)
|
|
1053
|
+
result = await self._session.execute(stmt)
|
|
1054
|
+
return list(result.scalars().all())
|
|
1055
|
+
|
|
1056
|
+
async def bulk_history_since(
|
|
1057
|
+
self,
|
|
1058
|
+
account_ids: list[str],
|
|
1059
|
+
window: str,
|
|
1060
|
+
since: datetime,
|
|
1061
|
+
*,
|
|
1062
|
+
cutoffs: dict[str, datetime] | None = None,
|
|
1063
|
+
per_account_row_cap: int | None = None,
|
|
1064
|
+
uncapped_recent_floor: datetime | None = None,
|
|
1065
|
+
) -> dict[str, list[UsageHistorySnapshot]]:
|
|
1066
|
+
"""Fetch minimal usage history fields for multiple accounts in a single query.
|
|
1067
|
+
|
|
1068
|
+
``since`` is the global floor. ``cutoffs`` optionally tightens the
|
|
1069
|
+
lookback per account: callers whose accounts have different window
|
|
1070
|
+
lengths would otherwise widen the fetch to the longest window for
|
|
1071
|
+
every account and discard the surplus in Python. When
|
|
1072
|
+
``per_account_row_cap`` is set on SQLite, ``_bulk_history_since_capped_sqlite``
|
|
1073
|
+
honors ``cutoffs``; the uncapped SQLite snapshot-cache path ignores
|
|
1074
|
+
``cutoffs`` (its cache is keyed on the shared floor) where callers keep
|
|
1075
|
+
their own per-account trimming.
|
|
1076
|
+
|
|
1077
|
+
``per_account_row_cap`` additionally bounds each account's slice to
|
|
1078
|
+
its newest rows inside the cutoff (supported on both PostgreSQL and
|
|
1079
|
+
SQLite). Live snapshot ingestion appends usage rows per proxied request,
|
|
1080
|
+
so a busy account's 7-day window can hold tens of thousands of rows
|
|
1081
|
+
while the projection consumers (EWMA depletion, weekly-pace burn/smoothing)
|
|
1082
|
+
only read the recent tail. Each capped slice keeps oldest-first ordering.
|
|
1083
|
+
|
|
1084
|
+
``uncapped_recent_floor`` exempts rows at or after the given time
|
|
1085
|
+
from the row cap: every in-cutoff row newer than the floor is always
|
|
1086
|
+
returned, and the cap bounds only the older remainder. Consumers
|
|
1087
|
+
whose math weighs every sample in a fixed time window equally (the
|
|
1088
|
+
weekly-pace smoothing mean) pass their window start here so a
|
|
1089
|
+
write-rate burst can never silently truncate that window, while
|
|
1090
|
+
tail-weighted consumers (EWMA) stay covered by the cap alone.
|
|
1091
|
+
Honored whenever ``per_account_row_cap`` is set on PostgreSQL or SQLite.
|
|
1092
|
+
"""
|
|
1093
|
+
if not account_ids:
|
|
1094
|
+
return {}
|
|
1095
|
+
bind = self._session.get_bind()
|
|
1096
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
1097
|
+
sqlite_path = _sqlite_path_from_bind(bind) if dialect == "sqlite" else None
|
|
1098
|
+
if sqlite_path is not None:
|
|
1099
|
+
if per_account_row_cap is not None:
|
|
1100
|
+
return await to_thread.run_sync(
|
|
1101
|
+
partial(
|
|
1102
|
+
_bulk_history_since_capped_sqlite,
|
|
1103
|
+
str(sqlite_path),
|
|
1104
|
+
list(account_ids),
|
|
1105
|
+
window,
|
|
1106
|
+
since,
|
|
1107
|
+
cutoffs=cutoffs,
|
|
1108
|
+
per_account_row_cap=per_account_row_cap,
|
|
1109
|
+
uncapped_recent_floor=uncapped_recent_floor,
|
|
1110
|
+
)
|
|
1111
|
+
)
|
|
1112
|
+
return await to_thread.run_sync(
|
|
1113
|
+
_bulk_history_since_sqlite,
|
|
1114
|
+
str(sqlite_path),
|
|
1115
|
+
list(account_ids),
|
|
1116
|
+
window,
|
|
1117
|
+
since,
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
if per_account_row_cap is not None and dialect == "postgresql":
|
|
1121
|
+
return await self._bulk_history_since_capped_postgresql(
|
|
1122
|
+
account_ids,
|
|
1123
|
+
window,
|
|
1124
|
+
since,
|
|
1125
|
+
cutoffs=cutoffs,
|
|
1126
|
+
per_account_row_cap=per_account_row_cap,
|
|
1127
|
+
uncapped_recent_floor=uncapped_recent_floor,
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
if cutoffs:
|
|
1131
|
+
recency_clause = or_(
|
|
1132
|
+
*(
|
|
1133
|
+
and_(
|
|
1134
|
+
UsageHistory.account_id == account_id,
|
|
1135
|
+
UsageHistory.recorded_at >= max(cutoffs.get(account_id, since), since),
|
|
1136
|
+
)
|
|
1137
|
+
for account_id in account_ids
|
|
1138
|
+
)
|
|
1139
|
+
)
|
|
1140
|
+
else:
|
|
1141
|
+
recency_clause = and_(
|
|
1142
|
+
UsageHistory.account_id.in_(account_ids),
|
|
1143
|
+
UsageHistory.recorded_at >= since,
|
|
1144
|
+
)
|
|
1145
|
+
stmt = (
|
|
1146
|
+
select(
|
|
1147
|
+
UsageHistory.id,
|
|
1148
|
+
UsageHistory.account_id,
|
|
1149
|
+
UsageHistory.used_percent,
|
|
1150
|
+
UsageHistory.recorded_at,
|
|
1151
|
+
UsageHistory.reset_at,
|
|
1152
|
+
UsageHistory.window_minutes,
|
|
1153
|
+
)
|
|
1154
|
+
.where(
|
|
1155
|
+
recency_clause,
|
|
1156
|
+
_window_clause(window),
|
|
1157
|
+
)
|
|
1158
|
+
.order_by(UsageHistory.account_id, UsageHistory.recorded_at.asc(), UsageHistory.id.asc())
|
|
1159
|
+
)
|
|
1160
|
+
result = await self._session.execute(stmt)
|
|
1161
|
+
grouped: dict[str, list[UsageHistorySnapshot]] = {}
|
|
1162
|
+
for id_, account_id, used_percent, recorded_at, reset_at, window_minutes in result.tuples():
|
|
1163
|
+
snapshot = UsageHistorySnapshot(
|
|
1164
|
+
id=int(id_),
|
|
1165
|
+
account_id=account_id,
|
|
1166
|
+
used_percent=float(used_percent),
|
|
1167
|
+
recorded_at=recorded_at,
|
|
1168
|
+
reset_at=float(reset_at) if reset_at is not None else None,
|
|
1169
|
+
window_minutes=int(window_minutes) if window_minutes is not None else None,
|
|
1170
|
+
)
|
|
1171
|
+
grouped.setdefault(account_id, []).append(snapshot)
|
|
1172
|
+
|
|
1173
|
+
if per_account_row_cap is not None:
|
|
1174
|
+
capped_grouped: dict[str, list[UsageHistorySnapshot]] = {}
|
|
1175
|
+
for account_id, snapshots in grouped.items():
|
|
1176
|
+
if uncapped_recent_floor is None:
|
|
1177
|
+
capped_grouped[account_id] = snapshots[-per_account_row_cap:] if per_account_row_cap > 0 else []
|
|
1178
|
+
else:
|
|
1179
|
+
cutoff = max(cutoffs.get(account_id, since), since) if cutoffs else since
|
|
1180
|
+
eff_floor = max(cutoff, uncapped_recent_floor)
|
|
1181
|
+
recent = [s for s in snapshots if s.recorded_at >= eff_floor]
|
|
1182
|
+
tail = [s for s in snapshots if s.recorded_at < eff_floor]
|
|
1183
|
+
capped_tail = tail[-per_account_row_cap:] if per_account_row_cap > 0 else []
|
|
1184
|
+
capped_grouped[account_id] = capped_tail + recent
|
|
1185
|
+
return capped_grouped
|
|
1186
|
+
|
|
1187
|
+
return grouped
|
|
1188
|
+
|
|
1189
|
+
async def _bulk_history_since_capped_postgresql(
|
|
1190
|
+
self,
|
|
1191
|
+
account_ids: list[str],
|
|
1192
|
+
window: str,
|
|
1193
|
+
since: datetime,
|
|
1194
|
+
*,
|
|
1195
|
+
cutoffs: dict[str, datetime] | None,
|
|
1196
|
+
per_account_row_cap: int,
|
|
1197
|
+
uncapped_recent_floor: datetime | None,
|
|
1198
|
+
) -> dict[str, list[UsageHistorySnapshot]]:
|
|
1199
|
+
"""Per-account newest-first capped fetch (PostgreSQL).
|
|
1200
|
+
|
|
1201
|
+
One lateral top-N probe per account instead of one shared range scan:
|
|
1202
|
+
the probe descends idx_usage_window_account_time_covering (or its
|
|
1203
|
+
raw-window twin) backward and stops at the cap or the account's
|
|
1204
|
+
cutoff, whichever comes first, so the read never touches the bulk of
|
|
1205
|
+
a dense account's window. The OR-of-cutoffs shape this replaces
|
|
1206
|
+
returned every in-window row (hundreds of thousands on dense
|
|
1207
|
+
deployments) to Python only for the projection consumers to use the
|
|
1208
|
+
recent tail.
|
|
1209
|
+
|
|
1210
|
+
With ``uncapped_recent_floor`` the probe splits into two disjoint
|
|
1211
|
+
branches over the same covering index: rows at or after the floor are
|
|
1212
|
+
returned in full (time-bounded, so still cheap), and the top-N cap
|
|
1213
|
+
applies only to rows between the cutoff and the floor. Snapshot
|
|
1214
|
+
ingestion writes per proxied request whenever the usage fingerprint
|
|
1215
|
+
moves, so a fixed row cap alone cannot guarantee it out-lasts a
|
|
1216
|
+
burst inside an equal-weight consumer window.
|
|
1217
|
+
"""
|
|
1218
|
+
value_columns = [
|
|
1219
|
+
column("account_id", String()),
|
|
1220
|
+
column("cutoff", UsageHistory.recorded_at.type),
|
|
1221
|
+
]
|
|
1222
|
+
if uncapped_recent_floor is not None:
|
|
1223
|
+
value_columns.append(column("uncapped_floor", UsageHistory.recorded_at.type))
|
|
1224
|
+
value_rows: list[tuple] = []
|
|
1225
|
+
for account_id in account_ids:
|
|
1226
|
+
cutoff = max(cutoffs.get(account_id, since), since) if cutoffs else since
|
|
1227
|
+
if uncapped_recent_floor is None:
|
|
1228
|
+
value_rows.append((account_id, cutoff))
|
|
1229
|
+
else:
|
|
1230
|
+
value_rows.append((account_id, cutoff, max(cutoff, uncapped_recent_floor)))
|
|
1231
|
+
account_cutoffs = values(*value_columns, name="account_cutoffs").data(value_rows)
|
|
1232
|
+
snapshot_columns = (
|
|
1233
|
+
UsageHistory.id,
|
|
1234
|
+
UsageHistory.account_id,
|
|
1235
|
+
UsageHistory.used_percent,
|
|
1236
|
+
UsageHistory.recorded_at,
|
|
1237
|
+
UsageHistory.reset_at,
|
|
1238
|
+
UsageHistory.window_minutes,
|
|
1239
|
+
)
|
|
1240
|
+
capped_tail = (
|
|
1241
|
+
select(*snapshot_columns)
|
|
1242
|
+
.where(
|
|
1243
|
+
UsageHistory.account_id == account_cutoffs.c.account_id,
|
|
1244
|
+
UsageHistory.recorded_at >= account_cutoffs.c.cutoff,
|
|
1245
|
+
*(
|
|
1246
|
+
(UsageHistory.recorded_at < account_cutoffs.c.uncapped_floor,)
|
|
1247
|
+
if uncapped_recent_floor is not None
|
|
1248
|
+
else ()
|
|
1249
|
+
),
|
|
1250
|
+
_window_clause(window),
|
|
1251
|
+
)
|
|
1252
|
+
.order_by(UsageHistory.recorded_at.desc(), UsageHistory.id.desc())
|
|
1253
|
+
.limit(per_account_row_cap)
|
|
1254
|
+
.correlate(account_cutoffs)
|
|
1255
|
+
)
|
|
1256
|
+
if uncapped_recent_floor is not None:
|
|
1257
|
+
uncapped_recent = (
|
|
1258
|
+
select(*snapshot_columns)
|
|
1259
|
+
.where(
|
|
1260
|
+
UsageHistory.account_id == account_cutoffs.c.account_id,
|
|
1261
|
+
UsageHistory.recorded_at >= account_cutoffs.c.uncapped_floor,
|
|
1262
|
+
_window_clause(window),
|
|
1263
|
+
)
|
|
1264
|
+
.correlate(account_cutoffs)
|
|
1265
|
+
)
|
|
1266
|
+
recent = union_all(uncapped_recent, capped_tail).lateral("recent")
|
|
1267
|
+
else:
|
|
1268
|
+
recent = capped_tail.lateral("recent")
|
|
1269
|
+
stmt = select(recent).select_from(account_cutoffs.join(recent, true()))
|
|
1270
|
+
result = await self._session.execute(stmt)
|
|
1271
|
+
grouped: dict[str, list[UsageHistorySnapshot]] = {}
|
|
1272
|
+
# Positional unpacking: Row attribute lookups dominated the per-row
|
|
1273
|
+
# cost of this loop on dense deployments (dashboard polls hydrate
|
|
1274
|
+
# thousands of rows per call). Column order follows snapshot_columns.
|
|
1275
|
+
for id_, account_id, used_percent, recorded_at, reset_at, window_minutes in result.tuples():
|
|
1276
|
+
snapshot = UsageHistorySnapshot(
|
|
1277
|
+
id=int(id_),
|
|
1278
|
+
account_id=account_id,
|
|
1279
|
+
used_percent=float(used_percent),
|
|
1280
|
+
recorded_at=recorded_at,
|
|
1281
|
+
reset_at=float(reset_at) if reset_at is not None else None,
|
|
1282
|
+
window_minutes=int(window_minutes) if window_minutes is not None else None,
|
|
1283
|
+
)
|
|
1284
|
+
grouped.setdefault(account_id, []).append(snapshot)
|
|
1285
|
+
for snapshots in grouped.values():
|
|
1286
|
+
snapshots.sort(key=lambda snapshot: (snapshot.recorded_at, snapshot.id))
|
|
1287
|
+
return grouped
|
|
1288
|
+
|
|
1289
|
+
async def trends_by_bucket(
|
|
1290
|
+
self,
|
|
1291
|
+
since: datetime,
|
|
1292
|
+
bucket_seconds: int = 21600,
|
|
1293
|
+
window: str | None = None,
|
|
1294
|
+
account_id: str | None = None,
|
|
1295
|
+
) -> list[UsageTrendBucket]:
|
|
1296
|
+
bind = self._session.get_bind()
|
|
1297
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
1298
|
+
if dialect == "postgresql":
|
|
1299
|
+
bucket_expr = func.floor(func.extract("epoch", UsageHistory.recorded_at) / bucket_seconds) * bucket_seconds
|
|
1300
|
+
else:
|
|
1301
|
+
epoch_col = sqlalchemy_cast(func.strftime("%s", UsageHistory.recorded_at), Integer)
|
|
1302
|
+
bucket_expr = sqlalchemy_cast(epoch_col / bucket_seconds, Integer) * bucket_seconds
|
|
1303
|
+
bucket_col = bucket_expr.label("bucket_epoch")
|
|
1304
|
+
|
|
1305
|
+
conditions: list = [UsageHistory.recorded_at >= since]
|
|
1306
|
+
if window:
|
|
1307
|
+
conditions.append(_window_clause(window))
|
|
1308
|
+
if account_id:
|
|
1309
|
+
conditions.append(UsageHistory.account_id == account_id)
|
|
1310
|
+
|
|
1311
|
+
window_expr = _normalized_window_expr()
|
|
1312
|
+
if dialect == "sqlite":
|
|
1313
|
+
base_rows = (
|
|
1314
|
+
select(
|
|
1315
|
+
bucket_col,
|
|
1316
|
+
UsageHistory.id.label("usage_id"),
|
|
1317
|
+
UsageHistory.account_id.label("account_id"),
|
|
1318
|
+
window_expr.label("window"),
|
|
1319
|
+
UsageHistory.used_percent.label("used_percent"),
|
|
1320
|
+
UsageHistory.recorded_at.label("recorded_at"),
|
|
1321
|
+
)
|
|
1322
|
+
.where(*conditions)
|
|
1323
|
+
.subquery()
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
aggregate_rows = (
|
|
1327
|
+
select(
|
|
1328
|
+
base_rows.c.bucket_epoch,
|
|
1329
|
+
base_rows.c.account_id,
|
|
1330
|
+
base_rows.c.window,
|
|
1331
|
+
func.avg(base_rows.c.used_percent).label("avg_used_percent"),
|
|
1332
|
+
func.count(base_rows.c.usage_id).label("samples"),
|
|
1333
|
+
func.max(base_rows.c.recorded_at).label("max_recorded_at"),
|
|
1334
|
+
)
|
|
1335
|
+
.group_by(
|
|
1336
|
+
base_rows.c.bucket_epoch,
|
|
1337
|
+
base_rows.c.account_id,
|
|
1338
|
+
base_rows.c.window,
|
|
1339
|
+
)
|
|
1340
|
+
.subquery()
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
latest_ids = (
|
|
1344
|
+
select(
|
|
1345
|
+
aggregate_rows.c.bucket_epoch,
|
|
1346
|
+
aggregate_rows.c.account_id,
|
|
1347
|
+
aggregate_rows.c.window,
|
|
1348
|
+
func.max(base_rows.c.usage_id).label("usage_id"),
|
|
1349
|
+
)
|
|
1350
|
+
.join(
|
|
1351
|
+
base_rows,
|
|
1352
|
+
and_(
|
|
1353
|
+
base_rows.c.bucket_epoch == aggregate_rows.c.bucket_epoch,
|
|
1354
|
+
base_rows.c.account_id == aggregate_rows.c.account_id,
|
|
1355
|
+
base_rows.c.window == aggregate_rows.c.window,
|
|
1356
|
+
base_rows.c.recorded_at == aggregate_rows.c.max_recorded_at,
|
|
1357
|
+
),
|
|
1358
|
+
)
|
|
1359
|
+
.group_by(
|
|
1360
|
+
aggregate_rows.c.bucket_epoch,
|
|
1361
|
+
aggregate_rows.c.account_id,
|
|
1362
|
+
aggregate_rows.c.window,
|
|
1363
|
+
)
|
|
1364
|
+
.subquery()
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
stmt = (
|
|
1368
|
+
select(
|
|
1369
|
+
aggregate_rows.c.bucket_epoch,
|
|
1370
|
+
aggregate_rows.c.account_id,
|
|
1371
|
+
aggregate_rows.c.window,
|
|
1372
|
+
aggregate_rows.c.avg_used_percent,
|
|
1373
|
+
aggregate_rows.c.samples,
|
|
1374
|
+
UsageHistory.reset_at,
|
|
1375
|
+
UsageHistory.window_minutes,
|
|
1376
|
+
UsageHistory.recorded_at,
|
|
1377
|
+
)
|
|
1378
|
+
.join(
|
|
1379
|
+
latest_ids,
|
|
1380
|
+
and_(
|
|
1381
|
+
latest_ids.c.bucket_epoch == aggregate_rows.c.bucket_epoch,
|
|
1382
|
+
latest_ids.c.account_id == aggregate_rows.c.account_id,
|
|
1383
|
+
latest_ids.c.window == aggregate_rows.c.window,
|
|
1384
|
+
),
|
|
1385
|
+
)
|
|
1386
|
+
.join(UsageHistory, UsageHistory.id == latest_ids.c.usage_id)
|
|
1387
|
+
.order_by(aggregate_rows.c.bucket_epoch)
|
|
1388
|
+
)
|
|
1389
|
+
else:
|
|
1390
|
+
base_rows = (
|
|
1391
|
+
select(
|
|
1392
|
+
bucket_col,
|
|
1393
|
+
UsageHistory.id.label("usage_id"),
|
|
1394
|
+
UsageHistory.account_id.label("account_id"),
|
|
1395
|
+
window_expr.label("window"),
|
|
1396
|
+
UsageHistory.used_percent.label("used_percent"),
|
|
1397
|
+
UsageHistory.reset_at.label("reset_at"),
|
|
1398
|
+
UsageHistory.window_minutes.label("window_minutes"),
|
|
1399
|
+
UsageHistory.recorded_at.label("recorded_at"),
|
|
1400
|
+
)
|
|
1401
|
+
.where(*conditions)
|
|
1402
|
+
.subquery()
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
aggregate_rows = (
|
|
1406
|
+
select(
|
|
1407
|
+
base_rows.c.bucket_epoch,
|
|
1408
|
+
base_rows.c.account_id,
|
|
1409
|
+
base_rows.c.window,
|
|
1410
|
+
func.avg(base_rows.c.used_percent).label("avg_used_percent"),
|
|
1411
|
+
func.count(base_rows.c.usage_id).label("samples"),
|
|
1412
|
+
)
|
|
1413
|
+
.group_by(
|
|
1414
|
+
base_rows.c.bucket_epoch,
|
|
1415
|
+
base_rows.c.account_id,
|
|
1416
|
+
base_rows.c.window,
|
|
1417
|
+
)
|
|
1418
|
+
.subquery()
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
latest_rows = select(
|
|
1422
|
+
base_rows.c.bucket_epoch,
|
|
1423
|
+
base_rows.c.account_id,
|
|
1424
|
+
base_rows.c.window,
|
|
1425
|
+
base_rows.c.reset_at,
|
|
1426
|
+
base_rows.c.window_minutes,
|
|
1427
|
+
base_rows.c.recorded_at,
|
|
1428
|
+
func.row_number()
|
|
1429
|
+
.over(
|
|
1430
|
+
partition_by=(base_rows.c.bucket_epoch, base_rows.c.account_id, base_rows.c.window),
|
|
1431
|
+
order_by=(base_rows.c.recorded_at.desc(), base_rows.c.usage_id.desc()),
|
|
1432
|
+
)
|
|
1433
|
+
.label("row_number"),
|
|
1434
|
+
).subquery()
|
|
1435
|
+
|
|
1436
|
+
stmt = (
|
|
1437
|
+
select(
|
|
1438
|
+
aggregate_rows.c.bucket_epoch,
|
|
1439
|
+
aggregate_rows.c.account_id,
|
|
1440
|
+
aggregate_rows.c.window,
|
|
1441
|
+
aggregate_rows.c.avg_used_percent,
|
|
1442
|
+
aggregate_rows.c.samples,
|
|
1443
|
+
latest_rows.c.reset_at,
|
|
1444
|
+
latest_rows.c.window_minutes,
|
|
1445
|
+
latest_rows.c.recorded_at,
|
|
1446
|
+
)
|
|
1447
|
+
.join(
|
|
1448
|
+
latest_rows,
|
|
1449
|
+
and_(
|
|
1450
|
+
latest_rows.c.bucket_epoch == aggregate_rows.c.bucket_epoch,
|
|
1451
|
+
latest_rows.c.account_id == aggregate_rows.c.account_id,
|
|
1452
|
+
latest_rows.c.window == aggregate_rows.c.window,
|
|
1453
|
+
latest_rows.c.row_number == 1,
|
|
1454
|
+
),
|
|
1455
|
+
)
|
|
1456
|
+
.order_by(aggregate_rows.c.bucket_epoch)
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
result = await self._session.execute(stmt)
|
|
1460
|
+
return [
|
|
1461
|
+
UsageTrendBucket(
|
|
1462
|
+
bucket_epoch=int(row.bucket_epoch),
|
|
1463
|
+
account_id=row.account_id,
|
|
1464
|
+
window=row.window,
|
|
1465
|
+
avg_used_percent=float(row.avg_used_percent) if row.avg_used_percent is not None else 0.0,
|
|
1466
|
+
samples=int(row.samples),
|
|
1467
|
+
reset_at=int(row.reset_at) if row.reset_at is not None else None,
|
|
1468
|
+
window_minutes=int(row.window_minutes) if row.window_minutes is not None else None,
|
|
1469
|
+
recorded_at=row.recorded_at,
|
|
1470
|
+
)
|
|
1471
|
+
for row in result.all()
|
|
1472
|
+
]
|
|
1473
|
+
|
|
1474
|
+
async def latest_window_minutes(self, window: str) -> int | None:
|
|
1475
|
+
conditions = _window_clause(window)
|
|
1476
|
+
result = await self._session.execute(select(func.max(UsageHistory.window_minutes)).where(conditions))
|
|
1477
|
+
value = result.scalar_one_or_none()
|
|
1478
|
+
return int(value) if value is not None else None
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
class AdditionalUsageRepository:
|
|
1482
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
1483
|
+
self._session = session
|
|
1484
|
+
|
|
1485
|
+
async def add_entry(
|
|
1486
|
+
self,
|
|
1487
|
+
account_id: str,
|
|
1488
|
+
limit_name: str,
|
|
1489
|
+
metered_feature: str,
|
|
1490
|
+
window: str,
|
|
1491
|
+
used_percent: float,
|
|
1492
|
+
reset_at: int | None = None,
|
|
1493
|
+
window_minutes: int | None = None,
|
|
1494
|
+
recorded_at: datetime | None = None,
|
|
1495
|
+
quota_key: str | None = None,
|
|
1496
|
+
) -> None:
|
|
1497
|
+
effective_quota_key = _resolve_additional_quota_key(
|
|
1498
|
+
quota_key=quota_key,
|
|
1499
|
+
limit_name=limit_name,
|
|
1500
|
+
metered_feature=metered_feature,
|
|
1501
|
+
)
|
|
1502
|
+
if effective_quota_key is None:
|
|
1503
|
+
raise ValueError("additional usage quota_key could not be determined")
|
|
1504
|
+
entry = AdditionalUsageHistory(
|
|
1505
|
+
account_id=account_id,
|
|
1506
|
+
quota_key=effective_quota_key,
|
|
1507
|
+
limit_name=limit_name,
|
|
1508
|
+
metered_feature=metered_feature,
|
|
1509
|
+
window=window,
|
|
1510
|
+
used_percent=used_percent,
|
|
1511
|
+
reset_at=reset_at,
|
|
1512
|
+
window_minutes=window_minutes,
|
|
1513
|
+
recorded_at=recorded_at or utcnow(),
|
|
1514
|
+
)
|
|
1515
|
+
async with sqlite_writer_section():
|
|
1516
|
+
# Telemetry write: this transaction only appends one
|
|
1517
|
+
# additional-usage-history row, so its commit may skip the
|
|
1518
|
+
# synchronous WAL flush.
|
|
1519
|
+
await relax_commit_durability(self._session)
|
|
1520
|
+
self._session.add(entry)
|
|
1521
|
+
await self._session.commit()
|
|
1522
|
+
|
|
1523
|
+
async def delete_for_account(self, account_id: str) -> None:
|
|
1524
|
+
stmt = delete(AdditionalUsageHistory).where(AdditionalUsageHistory.account_id == account_id)
|
|
1525
|
+
async with sqlite_writer_section():
|
|
1526
|
+
await self._session.execute(stmt)
|
|
1527
|
+
await self._session.commit()
|
|
1528
|
+
|
|
1529
|
+
async def delete_for_account_and_quota_key(self, account_id: str, quota_key: str) -> None:
|
|
1530
|
+
scope = _resolve_additional_quota_query_scope(quota_key=quota_key)
|
|
1531
|
+
if scope is None:
|
|
1532
|
+
raise ValueError("additional usage quota_key could not be determined")
|
|
1533
|
+
stmt = delete(AdditionalUsageHistory).where(
|
|
1534
|
+
AdditionalUsageHistory.account_id == account_id,
|
|
1535
|
+
_additional_quota_match_clause(scope),
|
|
1536
|
+
)
|
|
1537
|
+
async with sqlite_writer_section():
|
|
1538
|
+
await self._session.execute(stmt)
|
|
1539
|
+
await self._session.commit()
|
|
1540
|
+
|
|
1541
|
+
async def delete_for_account_and_limit(self, account_id: str, limit_name: str) -> None:
|
|
1542
|
+
await self.delete_for_account_and_quota_key(account_id, limit_name)
|
|
1543
|
+
|
|
1544
|
+
async def delete_for_account_quota_key_window(
|
|
1545
|
+
self,
|
|
1546
|
+
account_id: str,
|
|
1547
|
+
quota_key: str,
|
|
1548
|
+
window: str,
|
|
1549
|
+
) -> None:
|
|
1550
|
+
scope = _resolve_additional_quota_query_scope(quota_key=quota_key)
|
|
1551
|
+
if scope is None:
|
|
1552
|
+
raise ValueError("additional usage quota_key could not be determined")
|
|
1553
|
+
stmt = delete(AdditionalUsageHistory).where(
|
|
1554
|
+
AdditionalUsageHistory.account_id == account_id,
|
|
1555
|
+
_additional_quota_match_clause(scope),
|
|
1556
|
+
AdditionalUsageHistory.window == window,
|
|
1557
|
+
)
|
|
1558
|
+
async with sqlite_writer_section():
|
|
1559
|
+
await self._session.execute(stmt)
|
|
1560
|
+
await self._session.commit()
|
|
1561
|
+
|
|
1562
|
+
async def delete_for_account_limit_window(
|
|
1563
|
+
self,
|
|
1564
|
+
account_id: str,
|
|
1565
|
+
limit_name: str,
|
|
1566
|
+
window: str,
|
|
1567
|
+
) -> None:
|
|
1568
|
+
await self.delete_for_account_quota_key_window(account_id, limit_name, window)
|
|
1569
|
+
|
|
1570
|
+
async def latest_by_account(
|
|
1571
|
+
self,
|
|
1572
|
+
quota_key: str | None = None,
|
|
1573
|
+
window: str | None = None,
|
|
1574
|
+
*,
|
|
1575
|
+
limit_name: str | None = None,
|
|
1576
|
+
account_ids: Collection[str] | None = None,
|
|
1577
|
+
since: datetime | None = None,
|
|
1578
|
+
) -> dict[str, AdditionalUsageHistory]:
|
|
1579
|
+
"""Returns the latest effective entry per account for a canonical quota key + window."""
|
|
1580
|
+
scope = _resolve_additional_quota_query_scope(
|
|
1581
|
+
quota_key=quota_key,
|
|
1582
|
+
limit_name=limit_name,
|
|
1583
|
+
)
|
|
1584
|
+
if scope is None or window is None:
|
|
1585
|
+
raise ValueError("quota_key/limit_name and window are required")
|
|
1586
|
+
bind = self._session.get_bind()
|
|
1587
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
1588
|
+
if account_ids is not None:
|
|
1589
|
+
account_ids = list(account_ids)
|
|
1590
|
+
if not account_ids:
|
|
1591
|
+
return {}
|
|
1592
|
+
if dialect == "postgresql":
|
|
1593
|
+
# Correlated top-1 probes per (account × match value) instead of
|
|
1594
|
+
# DISTINCT ON: the scan shape costs one btree descent per probe
|
|
1595
|
+
# (ix_additional_usage_quota_window_latest for canonical values,
|
|
1596
|
+
# the lower(...) alias twins for registry aliases), so the read
|
|
1597
|
+
# scales with the candidate account count rather than with how
|
|
1598
|
+
# many history rows the quota key has accumulated. Merging the
|
|
1599
|
+
# per-value winners under the same (recorded_at, used_percent,
|
|
1600
|
+
# id) ordering reproduces the DISTINCT ON result exactly.
|
|
1601
|
+
probe_targets: list[tuple[Any, str]] = [
|
|
1602
|
+
(AdditionalUsageHistory.quota_key, value)
|
|
1603
|
+
for value in sorted(scope.quota_key_match_values or {scope.quota_key})
|
|
1604
|
+
]
|
|
1605
|
+
probe_targets.extend(
|
|
1606
|
+
(func.lower(AdditionalUsageHistory.limit_name), value)
|
|
1607
|
+
for value in sorted(scope.limit_name_match_values)
|
|
1608
|
+
)
|
|
1609
|
+
probe_targets.extend(
|
|
1610
|
+
(func.lower(AdditionalUsageHistory.metered_feature), value)
|
|
1611
|
+
for value in sorted(scope.metered_feature_match_values)
|
|
1612
|
+
)
|
|
1613
|
+
acct_stmt = select(Account.id)
|
|
1614
|
+
if account_ids is not None:
|
|
1615
|
+
acct_stmt = acct_stmt.where(Account.id.in_(account_ids))
|
|
1616
|
+
acct_subq = acct_stmt.subquery("accts")
|
|
1617
|
+
entries: dict[str, AdditionalUsageHistory] = {}
|
|
1618
|
+
for column_expr, value in probe_targets:
|
|
1619
|
+
lateral_conditions = [
|
|
1620
|
+
AdditionalUsageHistory.account_id == acct_subq.c.id,
|
|
1621
|
+
AdditionalUsageHistory.window == window,
|
|
1622
|
+
column_expr == value,
|
|
1623
|
+
]
|
|
1624
|
+
if since is not None:
|
|
1625
|
+
lateral_conditions.append(AdditionalUsageHistory.recorded_at >= since)
|
|
1626
|
+
lateral = (
|
|
1627
|
+
select(AdditionalUsageHistory.id)
|
|
1628
|
+
.where(*lateral_conditions)
|
|
1629
|
+
.order_by(
|
|
1630
|
+
AdditionalUsageHistory.recorded_at.desc(),
|
|
1631
|
+
AdditionalUsageHistory.used_percent.desc(),
|
|
1632
|
+
AdditionalUsageHistory.id.desc(),
|
|
1633
|
+
)
|
|
1634
|
+
.limit(1)
|
|
1635
|
+
.correlate(acct_subq)
|
|
1636
|
+
.lateral("latest")
|
|
1637
|
+
)
|
|
1638
|
+
id_query = (
|
|
1639
|
+
select(lateral.c.id)
|
|
1640
|
+
.select_from(acct_subq.outerjoin(lateral, true()))
|
|
1641
|
+
.where(lateral.c.id.is_not(None))
|
|
1642
|
+
)
|
|
1643
|
+
stmt = select(AdditionalUsageHistory).where(AdditionalUsageHistory.id.in_(id_query))
|
|
1644
|
+
result = await self._session.execute(stmt)
|
|
1645
|
+
_merge_latest_additional_usage_entries(entries, result.scalars().all())
|
|
1646
|
+
# Probe/plan order is not deterministic; callers pick response
|
|
1647
|
+
# metadata from the first entry, so restore the account order
|
|
1648
|
+
# the DISTINCT ON shape used to guarantee.
|
|
1649
|
+
return {account_id: entries[account_id] for account_id in sorted(entries)}
|
|
1650
|
+
|
|
1651
|
+
if dialect == "sqlite":
|
|
1652
|
+
return await self._latest_by_scope_sqlite_probes(
|
|
1653
|
+
scope,
|
|
1654
|
+
window,
|
|
1655
|
+
account_ids=account_ids,
|
|
1656
|
+
since=since,
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
conditions = [
|
|
1660
|
+
_additional_quota_match_clause(scope),
|
|
1661
|
+
AdditionalUsageHistory.window == window,
|
|
1662
|
+
]
|
|
1663
|
+
if account_ids is not None:
|
|
1664
|
+
conditions.append(AdditionalUsageHistory.account_id.in_(account_ids))
|
|
1665
|
+
if since is not None:
|
|
1666
|
+
conditions.append(AdditionalUsageHistory.recorded_at >= since)
|
|
1667
|
+
subq = (
|
|
1668
|
+
select(
|
|
1669
|
+
AdditionalUsageHistory.id.label("usage_id"),
|
|
1670
|
+
func.row_number()
|
|
1671
|
+
.over(
|
|
1672
|
+
partition_by=AdditionalUsageHistory.account_id,
|
|
1673
|
+
order_by=(
|
|
1674
|
+
AdditionalUsageHistory.recorded_at.desc(),
|
|
1675
|
+
AdditionalUsageHistory.used_percent.desc(),
|
|
1676
|
+
AdditionalUsageHistory.id.desc(),
|
|
1677
|
+
),
|
|
1678
|
+
)
|
|
1679
|
+
.label("row_number"),
|
|
1680
|
+
)
|
|
1681
|
+
.where(*conditions)
|
|
1682
|
+
.subquery()
|
|
1683
|
+
)
|
|
1684
|
+
stmt = (
|
|
1685
|
+
select(AdditionalUsageHistory)
|
|
1686
|
+
.join(subq, AdditionalUsageHistory.id == subq.c.usage_id)
|
|
1687
|
+
.where(subq.c.row_number == 1)
|
|
1688
|
+
)
|
|
1689
|
+
result = await self._session.execute(stmt)
|
|
1690
|
+
return {entry.account_id: entry for entry in result.scalars().all()}
|
|
1691
|
+
|
|
1692
|
+
async def _latest_by_scope_sqlite_probes(
|
|
1693
|
+
self,
|
|
1694
|
+
scope: AdditionalQuotaQueryScope,
|
|
1695
|
+
window: str,
|
|
1696
|
+
*,
|
|
1697
|
+
account_ids: Collection[str] | None = None,
|
|
1698
|
+
since: datetime | None = None,
|
|
1699
|
+
) -> dict[str, AdditionalUsageHistory]:
|
|
1700
|
+
account_values = list(account_ids) if account_ids is not None else None
|
|
1701
|
+
if account_values is not None and not account_values:
|
|
1702
|
+
return {}
|
|
1703
|
+
account_stmt = select(AdditionalUsageHistory.account_id).where(
|
|
1704
|
+
_additional_quota_match_clause(scope),
|
|
1705
|
+
AdditionalUsageHistory.window == window,
|
|
1706
|
+
)
|
|
1707
|
+
if account_values is not None:
|
|
1708
|
+
account_stmt = account_stmt.where(AdditionalUsageHistory.account_id.in_(account_values))
|
|
1709
|
+
if since is not None:
|
|
1710
|
+
account_stmt = account_stmt.where(AdditionalUsageHistory.recorded_at >= since)
|
|
1711
|
+
account_result = await self._session.execute(account_stmt.distinct())
|
|
1712
|
+
latest: dict[str, AdditionalUsageHistory] = {}
|
|
1713
|
+
for account_id in account_result.scalars().all():
|
|
1714
|
+
latest_stmt = (
|
|
1715
|
+
select(AdditionalUsageHistory)
|
|
1716
|
+
.where(
|
|
1717
|
+
AdditionalUsageHistory.account_id == account_id,
|
|
1718
|
+
_additional_quota_match_clause(scope),
|
|
1719
|
+
AdditionalUsageHistory.window == window,
|
|
1720
|
+
)
|
|
1721
|
+
.order_by(
|
|
1722
|
+
AdditionalUsageHistory.recorded_at.desc(),
|
|
1723
|
+
AdditionalUsageHistory.used_percent.desc(),
|
|
1724
|
+
AdditionalUsageHistory.id.desc(),
|
|
1725
|
+
)
|
|
1726
|
+
.limit(1)
|
|
1727
|
+
)
|
|
1728
|
+
if since is not None:
|
|
1729
|
+
latest_stmt = latest_stmt.where(AdditionalUsageHistory.recorded_at >= since)
|
|
1730
|
+
row_result = await self._session.execute(latest_stmt)
|
|
1731
|
+
entry = row_result.scalar_one_or_none()
|
|
1732
|
+
if entry is not None:
|
|
1733
|
+
latest[entry.account_id] = entry
|
|
1734
|
+
return latest
|
|
1735
|
+
|
|
1736
|
+
async def latest_by_quota_key(
|
|
1737
|
+
self,
|
|
1738
|
+
quota_key: str,
|
|
1739
|
+
window: str,
|
|
1740
|
+
*,
|
|
1741
|
+
account_ids: Collection[str] | None = None,
|
|
1742
|
+
since: datetime | None = None,
|
|
1743
|
+
) -> dict[str, AdditionalUsageHistory]:
|
|
1744
|
+
bind = self._session.get_bind()
|
|
1745
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
1746
|
+
if dialect == "sqlite":
|
|
1747
|
+
scope = _resolve_additional_quota_query_scope(quota_key=quota_key)
|
|
1748
|
+
if scope is None:
|
|
1749
|
+
raise ValueError("quota_key and window are required")
|
|
1750
|
+
return await self._latest_by_scope_sqlite_probes(
|
|
1751
|
+
scope,
|
|
1752
|
+
window,
|
|
1753
|
+
account_ids=account_ids,
|
|
1754
|
+
since=since,
|
|
1755
|
+
)
|
|
1756
|
+
return await self.latest_by_account(
|
|
1757
|
+
quota_key=quota_key,
|
|
1758
|
+
window=window,
|
|
1759
|
+
account_ids=account_ids,
|
|
1760
|
+
since=since,
|
|
1761
|
+
)
|
|
1762
|
+
|
|
1763
|
+
async def list_quota_keys(
|
|
1764
|
+
self,
|
|
1765
|
+
*,
|
|
1766
|
+
account_ids: Collection[str] | None = None,
|
|
1767
|
+
since: datetime | None = None,
|
|
1768
|
+
) -> list[str]:
|
|
1769
|
+
bind = self._session.get_bind()
|
|
1770
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
1771
|
+
if dialect == "postgresql" and since is None:
|
|
1772
|
+
label_rows = await self._distinct_label_tuples_postgres(account_ids=account_ids)
|
|
1773
|
+
else:
|
|
1774
|
+
stmt = select(
|
|
1775
|
+
AdditionalUsageHistory.quota_key,
|
|
1776
|
+
AdditionalUsageHistory.limit_name,
|
|
1777
|
+
AdditionalUsageHistory.metered_feature,
|
|
1778
|
+
).distinct()
|
|
1779
|
+
if account_ids is not None:
|
|
1780
|
+
stmt = stmt.where(AdditionalUsageHistory.account_id.in_(account_ids))
|
|
1781
|
+
if since is not None:
|
|
1782
|
+
stmt = stmt.where(AdditionalUsageHistory.recorded_at >= since)
|
|
1783
|
+
result = await self._session.execute(stmt)
|
|
1784
|
+
label_rows = [(row[0], row[1], row[2]) for row in result.all()]
|
|
1785
|
+
resolved_keys = {
|
|
1786
|
+
resolved_key
|
|
1787
|
+
for quota_key_value, limit_name_value, metered_feature_value in label_rows
|
|
1788
|
+
if (
|
|
1789
|
+
resolved_key := canonicalize_additional_quota_key(
|
|
1790
|
+
quota_key=quota_key_value,
|
|
1791
|
+
limit_name=limit_name_value,
|
|
1792
|
+
metered_feature=metered_feature_value,
|
|
1793
|
+
)
|
|
1794
|
+
)
|
|
1795
|
+
is not None
|
|
1796
|
+
}
|
|
1797
|
+
return sorted(resolved_keys)
|
|
1798
|
+
|
|
1799
|
+
async def _distinct_label_tuples_postgres(
|
|
1800
|
+
self,
|
|
1801
|
+
*,
|
|
1802
|
+
account_ids: Collection[str] | None = None,
|
|
1803
|
+
) -> list[tuple[str, str, str]]:
|
|
1804
|
+
"""Loose-index-scan emulation for the distinct label listing.
|
|
1805
|
+
|
|
1806
|
+
PostgreSQL has no native loose index scan, so a plain ``DISTINCT``
|
|
1807
|
+
over the label columns reads every history row. Row-value comparison
|
|
1808
|
+
probes over ``ix_additional_usage_distinct_labels`` instead step
|
|
1809
|
+
through the distinct ``(account_id, quota_key, limit_name,
|
|
1810
|
+
metered_feature)`` tuples — one btree descent per distinct tuple
|
|
1811
|
+
(the request-log facet listing emulates the same skip scan). Each
|
|
1812
|
+
probe is strictly ascending, so the walk terminates after the last
|
|
1813
|
+
distinct tuple.
|
|
1814
|
+
"""
|
|
1815
|
+
columns = (
|
|
1816
|
+
AdditionalUsageHistory.account_id,
|
|
1817
|
+
AdditionalUsageHistory.quota_key,
|
|
1818
|
+
AdditionalUsageHistory.limit_name,
|
|
1819
|
+
AdditionalUsageHistory.metered_feature,
|
|
1820
|
+
)
|
|
1821
|
+
ordered = tuple_(*columns)
|
|
1822
|
+
labels: list[tuple[str, str, str]] = []
|
|
1823
|
+
seen: set[tuple[str, str, str]] = set()
|
|
1824
|
+
cursor: tuple[str, str, str, str] | None = None
|
|
1825
|
+
while True:
|
|
1826
|
+
stmt = select(*columns)
|
|
1827
|
+
if account_ids is not None:
|
|
1828
|
+
stmt = stmt.where(AdditionalUsageHistory.account_id.in_(account_ids))
|
|
1829
|
+
if cursor is not None:
|
|
1830
|
+
stmt = stmt.where(ordered > cursor)
|
|
1831
|
+
stmt = stmt.order_by(*(column.asc() for column in columns)).limit(1)
|
|
1832
|
+
row = (await self._session.execute(stmt)).first()
|
|
1833
|
+
if row is None:
|
|
1834
|
+
return labels
|
|
1835
|
+
cursor = (row[0], row[1], row[2], row[3])
|
|
1836
|
+
label = (row[1], row[2], row[3])
|
|
1837
|
+
if label not in seen:
|
|
1838
|
+
seen.add(label)
|
|
1839
|
+
labels.append(label)
|
|
1840
|
+
|
|
1841
|
+
async def list_limit_names(
|
|
1842
|
+
self,
|
|
1843
|
+
*,
|
|
1844
|
+
account_ids: Collection[str] | None = None,
|
|
1845
|
+
since: datetime | None = None,
|
|
1846
|
+
) -> list[str]:
|
|
1847
|
+
return await self.list_quota_keys(account_ids=account_ids, since=since)
|
|
1848
|
+
|
|
1849
|
+
async def history_since(
|
|
1850
|
+
self,
|
|
1851
|
+
account_id: str,
|
|
1852
|
+
quota_key: str | None = None,
|
|
1853
|
+
window: str | None = None,
|
|
1854
|
+
since: datetime | None = None,
|
|
1855
|
+
*,
|
|
1856
|
+
limit_name: str | None = None,
|
|
1857
|
+
) -> list[AdditionalUsageHistory]:
|
|
1858
|
+
"""Returns time-series entries for EWMA computation."""
|
|
1859
|
+
scope = _resolve_additional_quota_query_scope(
|
|
1860
|
+
quota_key=quota_key,
|
|
1861
|
+
limit_name=limit_name,
|
|
1862
|
+
)
|
|
1863
|
+
if scope is None or window is None or since is None:
|
|
1864
|
+
raise ValueError("account_id, quota_key/limit_name, window, and since are required")
|
|
1865
|
+
stmt = (
|
|
1866
|
+
select(AdditionalUsageHistory)
|
|
1867
|
+
.where(
|
|
1868
|
+
AdditionalUsageHistory.account_id == account_id,
|
|
1869
|
+
_additional_quota_match_clause(scope),
|
|
1870
|
+
AdditionalUsageHistory.window == window,
|
|
1871
|
+
AdditionalUsageHistory.recorded_at >= since,
|
|
1872
|
+
)
|
|
1873
|
+
.order_by(AdditionalUsageHistory.recorded_at.asc())
|
|
1874
|
+
)
|
|
1875
|
+
result = await self._session.execute(stmt)
|
|
1876
|
+
return list(result.scalars().all())
|
|
1877
|
+
|
|
1878
|
+
async def latest_recorded_at_for_account(self, account_id: str) -> datetime | None:
|
|
1879
|
+
"""Return the most recent recorded_at for any additional usage entry of this account."""
|
|
1880
|
+
stmt = select(func.max(AdditionalUsageHistory.recorded_at)).where(
|
|
1881
|
+
AdditionalUsageHistory.account_id == account_id
|
|
1882
|
+
)
|
|
1883
|
+
result = await self._session.execute(stmt)
|
|
1884
|
+
return result.scalar_one_or_none()
|
|
1885
|
+
|
|
1886
|
+
async def latest_recorded_at(self) -> datetime | None:
|
|
1887
|
+
"""Return the most recent recorded_at across all additional usage entries."""
|
|
1888
|
+
stmt = select(func.max(AdditionalUsageHistory.recorded_at))
|
|
1889
|
+
result = await self._session.execute(stmt)
|
|
1890
|
+
return result.scalar_one_or_none()
|