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,1200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import BigInteger, Integer, cast, delete, func, insert, literal, or_, select, text, true, update
|
|
10
|
+
from sqlalchemy.exc import OperationalError
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
from sqlalchemy.orm import load_only, raiseload, selectinload
|
|
13
|
+
|
|
14
|
+
from app.core.utils.time import utcnow
|
|
15
|
+
from app.db.models import (
|
|
16
|
+
Account,
|
|
17
|
+
AccountStatus,
|
|
18
|
+
ApiKey,
|
|
19
|
+
ApiKeyAccountAssignment,
|
|
20
|
+
ApiKeyDeactivatedReason,
|
|
21
|
+
ApiKeyLimit,
|
|
22
|
+
ApiKeyModelSourceAssignment,
|
|
23
|
+
ApiKeyUsageReservation,
|
|
24
|
+
ApiKeyUsageReservationItem,
|
|
25
|
+
ApiKeyUsageRollup,
|
|
26
|
+
DashboardUser,
|
|
27
|
+
DashboardUserStatus,
|
|
28
|
+
LimitType,
|
|
29
|
+
LimitWindow,
|
|
30
|
+
ModelSource,
|
|
31
|
+
RequestLog,
|
|
32
|
+
RequestUsageHourlyRollup,
|
|
33
|
+
)
|
|
34
|
+
from app.db.session import sqlite_writer_section
|
|
35
|
+
from app.modules.accounts.usage_rollup import api_key_usage_aggregate_stmt, read_api_key_rollup_state
|
|
36
|
+
from app.modules.accounts.usage_time_rollup import HOURLY_BUCKET_SECONDS, WARMUP_REQUEST_KINDS, to_dimension
|
|
37
|
+
from app.modules.accounts.usage_time_rollup_read import RawWindow, raw_windows_clause, read_hourly_window
|
|
38
|
+
from app.modules.api_keys.limit_windows import advance_limit_reset
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApiKeyOwnerDisabledError(ValueError):
|
|
42
|
+
"""Re-enabling a key whose owner account is disabled is refused."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ReservationResult:
|
|
47
|
+
success: bool
|
|
48
|
+
limit_id: int
|
|
49
|
+
current_value: int | None
|
|
50
|
+
max_value: int | None
|
|
51
|
+
reset_at: datetime | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class UsageReservationItemData:
|
|
56
|
+
limit_id: int
|
|
57
|
+
limit_type: LimitType
|
|
58
|
+
reserved_delta: int
|
|
59
|
+
expected_reset_at: datetime
|
|
60
|
+
actual_delta: int | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class UsageReservationData:
|
|
65
|
+
reservation_id: str
|
|
66
|
+
api_key_id: str
|
|
67
|
+
model: str
|
|
68
|
+
status: str
|
|
69
|
+
items: list[UsageReservationItemData]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class ApiKeyUsageSummary:
|
|
74
|
+
request_count: int
|
|
75
|
+
total_tokens: int
|
|
76
|
+
cached_input_tokens: int
|
|
77
|
+
total_cost_usd: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class ApiKeyTrendBucket:
|
|
82
|
+
bucket_epoch: int
|
|
83
|
+
total_tokens: int
|
|
84
|
+
total_cost_usd: float
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class ApiKeyUsageTotals:
|
|
89
|
+
total_requests: int
|
|
90
|
+
total_tokens: int
|
|
91
|
+
cached_input_tokens: int
|
|
92
|
+
total_cost_usd: float
|
|
93
|
+
account_costs: list["ApiKeyAccountCost"] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class ApiKeyAccountCost:
|
|
98
|
+
account_id: str | None
|
|
99
|
+
email: str | None
|
|
100
|
+
cost_usd: float
|
|
101
|
+
is_deleted: bool = False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Unset(Enum):
|
|
105
|
+
UNSET = "UNSET"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_UNSET = _Unset.UNSET
|
|
109
|
+
_EXPIRED_LIMIT_RESET_BATCH_SIZE = 500
|
|
110
|
+
_STALE_USAGE_RESERVATION_RELEASE_BATCH_SIZE = 500
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ApiKeysRepository:
|
|
114
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
115
|
+
self._session = session
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _build_account_costs(rows: Sequence[object]) -> list[ApiKeyAccountCost]:
|
|
119
|
+
account_costs: list[ApiKeyAccountCost] = []
|
|
120
|
+
deleted_cost = 0.0
|
|
121
|
+
|
|
122
|
+
for row in rows:
|
|
123
|
+
cost = round(float(getattr(row, "cost_usd", 0.0) or 0.0), 6)
|
|
124
|
+
if cost <= 0:
|
|
125
|
+
continue
|
|
126
|
+
is_deleted = bool(getattr(row, "is_deleted", False))
|
|
127
|
+
if is_deleted:
|
|
128
|
+
deleted_cost += cost
|
|
129
|
+
continue
|
|
130
|
+
account_costs.append(
|
|
131
|
+
ApiKeyAccountCost(
|
|
132
|
+
account_id=getattr(row, "account_id", None),
|
|
133
|
+
email=getattr(row, "email", None),
|
|
134
|
+
cost_usd=cost,
|
|
135
|
+
is_deleted=False,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if deleted_cost > 0:
|
|
140
|
+
account_costs.append(
|
|
141
|
+
ApiKeyAccountCost(
|
|
142
|
+
account_id=None,
|
|
143
|
+
email=None,
|
|
144
|
+
cost_usd=round(deleted_cost, 6),
|
|
145
|
+
is_deleted=True,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
account_costs.sort(key=lambda item: item.cost_usd, reverse=True)
|
|
150
|
+
return account_costs
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _exclude_warmup_clause():
|
|
154
|
+
return RequestLog.request_kind.not_in(("warmup", "limit_warmup"))
|
|
155
|
+
|
|
156
|
+
def _select_api_key(self):
|
|
157
|
+
return (
|
|
158
|
+
select(ApiKey)
|
|
159
|
+
.execution_options(populate_existing=True)
|
|
160
|
+
.options(
|
|
161
|
+
selectinload(ApiKey.limits),
|
|
162
|
+
selectinload(ApiKey.account_assignments),
|
|
163
|
+
selectinload(ApiKey.source_assignments),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def create(self, row: ApiKey, *, commit: bool = True) -> ApiKey:
|
|
168
|
+
self._session.add(row)
|
|
169
|
+
if commit:
|
|
170
|
+
await self._session.commit()
|
|
171
|
+
created = await self.get_by_id(row.id)
|
|
172
|
+
assert created is not None
|
|
173
|
+
return created
|
|
174
|
+
|
|
175
|
+
async def get_by_id(self, key_id: str) -> ApiKey | None:
|
|
176
|
+
result = await self._session.execute(self._select_api_key().where(ApiKey.id == key_id))
|
|
177
|
+
return result.scalar_one_or_none()
|
|
178
|
+
|
|
179
|
+
async def get_for_limit_enforcement(self, key_id: str) -> ApiKey | None:
|
|
180
|
+
"""Admission-path load for ``enforce_limits_for_request``.
|
|
181
|
+
|
|
182
|
+
The enforcement transaction reads only ``is_active``/``expires_at``
|
|
183
|
+
plus the ``limits`` collection, so this skips the
|
|
184
|
+
``account_assignments``/``source_assignments`` selectin round trips
|
|
185
|
+
that ``get_by_id`` pays on every proxied request. ``raiseload`` keeps
|
|
186
|
+
the narrowing fail-loud: any future enforcement code that touches an
|
|
187
|
+
unlisted column or relationship raises instead of silently lazy
|
|
188
|
+
loading. ``populate_existing`` stays required because the lazy limit
|
|
189
|
+
reset commits mid-enforcement and the refetch must re-hydrate rows
|
|
190
|
+
already in the identity map (sessions use ``expire_on_commit=False``).
|
|
191
|
+
|
|
192
|
+
Session-isolation invariant: ``populate_existing`` + ``raiseload``
|
|
193
|
+
would poison a *fully loaded* ``ApiKey`` already in this session's
|
|
194
|
+
identity map — re-populating it flips its unlisted columns and
|
|
195
|
+
relationships into raise-on-access state for every other holder of
|
|
196
|
+
that instance. That is unreachable today because every caller runs
|
|
197
|
+
this query in a dedicated short-lived session that never full-loads
|
|
198
|
+
an ``ApiKey`` first (``_enforce_request_limits`` and the websocket
|
|
199
|
+
reservation path open fresh background sessions/repo bundles; the
|
|
200
|
+
quota-planner warmup session never loads ``ApiKey`` rows), and the
|
|
201
|
+
only prior instance this query can re-populate is the one it loaded
|
|
202
|
+
itself with these same options. Do not call this on a session that
|
|
203
|
+
may already hold a fully loaded ``ApiKey`` (e.g. via ``get_by_id`` /
|
|
204
|
+
``get_by_hash``) without dropping the narrowing first.
|
|
205
|
+
"""
|
|
206
|
+
result = await self._session.execute(
|
|
207
|
+
select(ApiKey)
|
|
208
|
+
.execution_options(populate_existing=True)
|
|
209
|
+
.options(
|
|
210
|
+
load_only(ApiKey.is_active, ApiKey.expires_at, raiseload=True),
|
|
211
|
+
selectinload(ApiKey.limits),
|
|
212
|
+
raiseload(ApiKey.account_assignments),
|
|
213
|
+
raiseload(ApiKey.source_assignments),
|
|
214
|
+
)
|
|
215
|
+
.where(ApiKey.id == key_id)
|
|
216
|
+
)
|
|
217
|
+
return result.scalar_one_or_none()
|
|
218
|
+
|
|
219
|
+
async def get_by_hash(self, key_hash: str) -> ApiKey | None:
|
|
220
|
+
result = await self._session.execute(self._select_api_key().where(ApiKey.key_hash == key_hash))
|
|
221
|
+
return result.scalar_one_or_none()
|
|
222
|
+
|
|
223
|
+
async def list_all(self) -> list[ApiKey]:
|
|
224
|
+
result = await self._session.execute(self._select_api_key().order_by(ApiKey.created_at.desc()))
|
|
225
|
+
return list(result.scalars().unique().all())
|
|
226
|
+
|
|
227
|
+
async def list_accounts_by_ids(self, account_ids: list[str]) -> list[Account]:
|
|
228
|
+
if not account_ids:
|
|
229
|
+
return []
|
|
230
|
+
result = await self._session.execute(
|
|
231
|
+
select(Account)
|
|
232
|
+
.options(load_only(Account.id, Account.plan_type, Account.status))
|
|
233
|
+
.where(Account.id.in_(account_ids))
|
|
234
|
+
# An account marked for background deletion is already deleted
|
|
235
|
+
# from the operator's point of view: assignment validation must
|
|
236
|
+
# reject it (the synchronous delete removed the row outright) and
|
|
237
|
+
# pooled-usage projections must not count it while its rows drain.
|
|
238
|
+
.where(Account.delete_requested_at.is_(None))
|
|
239
|
+
)
|
|
240
|
+
return list(result.scalars().all())
|
|
241
|
+
|
|
242
|
+
async def list_model_sources_by_ids(self, source_ids: list[str]) -> list[ModelSource]:
|
|
243
|
+
if not source_ids:
|
|
244
|
+
return []
|
|
245
|
+
result = await self._session.execute(select(ModelSource).where(ModelSource.id.in_(source_ids)))
|
|
246
|
+
return list(result.scalars().all())
|
|
247
|
+
|
|
248
|
+
async def list_all_accounts(self) -> list[Account]:
|
|
249
|
+
result = await self._session.execute(
|
|
250
|
+
select(Account)
|
|
251
|
+
.options(load_only(Account.id, Account.plan_type, Account.status))
|
|
252
|
+
.where(~Account.status.in_((AccountStatus.DEACTIVATED, AccountStatus.PAUSED)))
|
|
253
|
+
# Status alone is not enough: an unfenced pre-upgrade replica can
|
|
254
|
+
# briefly replace a marked account's terminal status during a
|
|
255
|
+
# rolling deploy, and a deleted account must never re-enter the
|
|
256
|
+
# unscoped pooled-usage projections.
|
|
257
|
+
.where(Account.delete_requested_at.is_(None))
|
|
258
|
+
)
|
|
259
|
+
return list(result.scalars().all())
|
|
260
|
+
|
|
261
|
+
async def list_usage_summary_by_key(self, api_key_ids: list[str] | None = None) -> dict[str, ApiKeyUsageSummary]:
|
|
262
|
+
folded, watermark = await read_api_key_rollup_state(self._session, api_key_ids)
|
|
263
|
+
merged: dict[str, list[float]] = {
|
|
264
|
+
key_id: [
|
|
265
|
+
sums.request_count,
|
|
266
|
+
sums.input_tokens,
|
|
267
|
+
sums.output_tokens,
|
|
268
|
+
sums.cached_input_tokens,
|
|
269
|
+
sums.total_cost_usd,
|
|
270
|
+
]
|
|
271
|
+
for key_id, sums in folded.items()
|
|
272
|
+
}
|
|
273
|
+
tail_stmt = api_key_usage_aggregate_stmt(api_key_ids=api_key_ids, after_exclusive=watermark)
|
|
274
|
+
result = await self._session.execute(tail_stmt)
|
|
275
|
+
for (
|
|
276
|
+
api_key_id,
|
|
277
|
+
request_count,
|
|
278
|
+
input_tokens,
|
|
279
|
+
output_tokens,
|
|
280
|
+
cached_input_tokens,
|
|
281
|
+
total_cost_usd,
|
|
282
|
+
) in result.all():
|
|
283
|
+
if not api_key_id:
|
|
284
|
+
continue
|
|
285
|
+
totals = merged.setdefault(api_key_id, [0, 0, 0, 0, 0.0])
|
|
286
|
+
totals[0] += int(request_count or 0)
|
|
287
|
+
totals[1] += int(input_tokens or 0)
|
|
288
|
+
totals[2] += int(output_tokens or 0)
|
|
289
|
+
totals[3] += int(cached_input_tokens or 0)
|
|
290
|
+
totals[4] += float(total_cost_usd or 0.0)
|
|
291
|
+
|
|
292
|
+
summaries: dict[str, ApiKeyUsageSummary] = {}
|
|
293
|
+
for api_key_id, (request_count, input_sum, output_sum, cached_sum, total_cost_usd) in merged.items():
|
|
294
|
+
input_total = int(input_sum)
|
|
295
|
+
output_total = int(output_sum)
|
|
296
|
+
cached_total = max(0, min(int(cached_sum), input_total))
|
|
297
|
+
summaries[api_key_id] = ApiKeyUsageSummary(
|
|
298
|
+
request_count=int(request_count),
|
|
299
|
+
total_tokens=input_total + output_total,
|
|
300
|
+
cached_input_tokens=cached_total,
|
|
301
|
+
total_cost_usd=round(float(total_cost_usd), 6),
|
|
302
|
+
)
|
|
303
|
+
return summaries
|
|
304
|
+
|
|
305
|
+
async def get_usage_summary_by_key_id(self, key_id: str) -> ApiKeyUsageSummary:
|
|
306
|
+
"""Return aggregate usage totals for a single API key (zeroes if no logs)."""
|
|
307
|
+
summaries = await self.list_usage_summary_by_key([key_id])
|
|
308
|
+
return summaries.get(
|
|
309
|
+
key_id,
|
|
310
|
+
ApiKeyUsageSummary(request_count=0, total_tokens=0, cached_input_tokens=0, total_cost_usd=0.0),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
async def get_limit_usage_value(
|
|
314
|
+
self,
|
|
315
|
+
key_id: str,
|
|
316
|
+
*,
|
|
317
|
+
limit_type: LimitType,
|
|
318
|
+
since: datetime,
|
|
319
|
+
until: datetime,
|
|
320
|
+
model_filter: str | None,
|
|
321
|
+
) -> int:
|
|
322
|
+
if limit_type == LimitType.CREDITS:
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
if limit_type == LimitType.TOTAL_TOKENS:
|
|
326
|
+
value_expr = func.coalesce(RequestLog.input_tokens, 0) + func.coalesce(
|
|
327
|
+
RequestLog.output_tokens,
|
|
328
|
+
RequestLog.reasoning_tokens,
|
|
329
|
+
0,
|
|
330
|
+
)
|
|
331
|
+
elif limit_type == LimitType.INPUT_TOKENS:
|
|
332
|
+
value_expr = func.coalesce(RequestLog.input_tokens, 0)
|
|
333
|
+
elif limit_type == LimitType.OUTPUT_TOKENS:
|
|
334
|
+
value_expr = func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
|
|
335
|
+
elif limit_type == LimitType.COST_USD:
|
|
336
|
+
value_expr = cast(func.floor(func.coalesce(RequestLog.cost_usd, 0.0) * 1_000_000), BigInteger)
|
|
337
|
+
else:
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
stmt = select(func.coalesce(func.sum(value_expr), 0)).where(
|
|
341
|
+
RequestLog.api_key_id == key_id,
|
|
342
|
+
RequestLog.status == "success",
|
|
343
|
+
self._exclude_warmup_clause(),
|
|
344
|
+
RequestLog.requested_at >= since,
|
|
345
|
+
RequestLog.requested_at < until,
|
|
346
|
+
)
|
|
347
|
+
if model_filter is not None:
|
|
348
|
+
stmt = stmt.where(RequestLog.model == model_filter)
|
|
349
|
+
|
|
350
|
+
result = await self._session.execute(stmt)
|
|
351
|
+
value = result.scalar_one()
|
|
352
|
+
return int(value or 0)
|
|
353
|
+
|
|
354
|
+
async def update(
|
|
355
|
+
self,
|
|
356
|
+
key_id: str,
|
|
357
|
+
*,
|
|
358
|
+
name: str | _Unset = _UNSET,
|
|
359
|
+
allowed_models: str | None | _Unset = _UNSET,
|
|
360
|
+
apply_to_codex_model: bool | _Unset = _UNSET,
|
|
361
|
+
enforced_model: str | None | _Unset = _UNSET,
|
|
362
|
+
enforced_reasoning_effort: str | None | _Unset = _UNSET,
|
|
363
|
+
allowed_reasoning_efforts: str | None | _Unset = _UNSET,
|
|
364
|
+
enforced_service_tier: str | None | _Unset = _UNSET,
|
|
365
|
+
traffic_class: str | _Unset = _UNSET,
|
|
366
|
+
transport_policy_override: str | None | _Unset = _UNSET,
|
|
367
|
+
thread_cache_identity_override: str | None | _Unset = _UNSET,
|
|
368
|
+
usage_sections: str | _Unset = _UNSET,
|
|
369
|
+
account_assignment_scope_enabled: bool | _Unset = _UNSET,
|
|
370
|
+
source_assignment_scope_enabled: bool | _Unset = _UNSET,
|
|
371
|
+
expires_at: datetime | None | _Unset = _UNSET,
|
|
372
|
+
is_active: bool | _Unset = _UNSET,
|
|
373
|
+
key_hash: str | _Unset = _UNSET,
|
|
374
|
+
key_prefix: str | _Unset = _UNSET,
|
|
375
|
+
commit: bool = True,
|
|
376
|
+
) -> ApiKey | None:
|
|
377
|
+
row = await self.get_by_id(key_id)
|
|
378
|
+
if row is None:
|
|
379
|
+
return None
|
|
380
|
+
if is_active is not _UNSET and is_active and not row.is_active and row.owner_user_id is not None:
|
|
381
|
+
# Before the first field is assigned: any assignment makes the key
|
|
382
|
+
# row dirty, and the next statement would autoflush its UPDATE --
|
|
383
|
+
# taking the key row before the owner row, the opposite order from
|
|
384
|
+
# the disable cascade, which is a deadlock rather than a refusal.
|
|
385
|
+
if await self._owner_is_disabled(row.owner_user_id):
|
|
386
|
+
raise ApiKeyOwnerDisabledError("The key's owner is disabled; re-enable the account first")
|
|
387
|
+
if name is not _UNSET:
|
|
388
|
+
assert isinstance(name, str)
|
|
389
|
+
row.name = name
|
|
390
|
+
if allowed_models is not _UNSET:
|
|
391
|
+
assert allowed_models is None or isinstance(allowed_models, str)
|
|
392
|
+
row.allowed_models = allowed_models
|
|
393
|
+
if apply_to_codex_model is not _UNSET:
|
|
394
|
+
assert isinstance(apply_to_codex_model, bool)
|
|
395
|
+
row.apply_to_codex_model = apply_to_codex_model
|
|
396
|
+
if enforced_model is not _UNSET:
|
|
397
|
+
assert enforced_model is None or isinstance(enforced_model, str)
|
|
398
|
+
row.enforced_model = enforced_model
|
|
399
|
+
if enforced_reasoning_effort is not _UNSET:
|
|
400
|
+
assert enforced_reasoning_effort is None or isinstance(enforced_reasoning_effort, str)
|
|
401
|
+
row.enforced_reasoning_effort = enforced_reasoning_effort
|
|
402
|
+
if allowed_reasoning_efforts is not _UNSET:
|
|
403
|
+
assert allowed_reasoning_efforts is None or isinstance(allowed_reasoning_efforts, str)
|
|
404
|
+
row.allowed_reasoning_efforts = allowed_reasoning_efforts
|
|
405
|
+
if enforced_service_tier is not _UNSET:
|
|
406
|
+
assert enforced_service_tier is None or isinstance(enforced_service_tier, str)
|
|
407
|
+
row.enforced_service_tier = enforced_service_tier
|
|
408
|
+
if traffic_class is not _UNSET:
|
|
409
|
+
assert isinstance(traffic_class, str)
|
|
410
|
+
row.traffic_class = traffic_class
|
|
411
|
+
if transport_policy_override is not _UNSET:
|
|
412
|
+
assert transport_policy_override is None or isinstance(transport_policy_override, str)
|
|
413
|
+
row.transport_policy_override = transport_policy_override
|
|
414
|
+
if thread_cache_identity_override is not _UNSET:
|
|
415
|
+
assert thread_cache_identity_override is None or isinstance(thread_cache_identity_override, str)
|
|
416
|
+
row.thread_cache_identity_override = thread_cache_identity_override
|
|
417
|
+
if usage_sections is not _UNSET:
|
|
418
|
+
assert isinstance(usage_sections, str)
|
|
419
|
+
row.usage_sections = usage_sections
|
|
420
|
+
if account_assignment_scope_enabled is not _UNSET:
|
|
421
|
+
assert isinstance(account_assignment_scope_enabled, bool)
|
|
422
|
+
row.account_assignment_scope_enabled = account_assignment_scope_enabled
|
|
423
|
+
if source_assignment_scope_enabled is not _UNSET:
|
|
424
|
+
assert isinstance(source_assignment_scope_enabled, bool)
|
|
425
|
+
row.source_assignment_scope_enabled = source_assignment_scope_enabled
|
|
426
|
+
if expires_at is not _UNSET:
|
|
427
|
+
assert expires_at is None or isinstance(expires_at, datetime)
|
|
428
|
+
row.expires_at = expires_at
|
|
429
|
+
if is_active is not _UNSET:
|
|
430
|
+
assert isinstance(is_active, bool)
|
|
431
|
+
if is_active and not row.is_active:
|
|
432
|
+
# The owner gate already ran above, under its row lock.
|
|
433
|
+
row.deactivated_reason = None
|
|
434
|
+
elif not is_active:
|
|
435
|
+
# An explicit revoke wins over the owner cascade: reactivate-keys
|
|
436
|
+
# must not resurrect a key an operator blocked by hand.
|
|
437
|
+
row.deactivated_reason = ApiKeyDeactivatedReason.MANUAL.value
|
|
438
|
+
row.is_active = is_active
|
|
439
|
+
if key_hash is not _UNSET:
|
|
440
|
+
assert isinstance(key_hash, str)
|
|
441
|
+
row.key_hash = key_hash
|
|
442
|
+
if key_prefix is not _UNSET:
|
|
443
|
+
assert isinstance(key_prefix, str)
|
|
444
|
+
row.key_prefix = key_prefix
|
|
445
|
+
if commit:
|
|
446
|
+
await self._session.commit()
|
|
447
|
+
return await self.get_by_id(key_id)
|
|
448
|
+
|
|
449
|
+
async def _owner_is_disabled(self, owner_user_id: str) -> bool:
|
|
450
|
+
"""Read the owner's status under a lock so a concurrent disable cannot land between check and write.
|
|
451
|
+
|
|
452
|
+
SQLite: take the database write lock now (``BEGIN IMMEDIATE``, as the
|
|
453
|
+
account mutations do), so the disable either committed before this
|
|
454
|
+
read or waits for this transaction. PostgreSQL: lock the owner row
|
|
455
|
+
``FOR UPDATE``; the disable's UPDATE of that row blocks until commit.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
stmt = select(DashboardUser.status).where(DashboardUser.id == owner_user_id)
|
|
459
|
+
if self._session.get_bind().dialect.name == "sqlite":
|
|
460
|
+
try:
|
|
461
|
+
await self._session.execute(text("BEGIN IMMEDIATE"))
|
|
462
|
+
except OperationalError as exc:
|
|
463
|
+
if "within a transaction" not in str(exc).lower():
|
|
464
|
+
raise
|
|
465
|
+
await self._session.execute(text("UPDATE api_keys SET id = id WHERE 1 = 0"))
|
|
466
|
+
else:
|
|
467
|
+
stmt = stmt.with_for_update()
|
|
468
|
+
status = (await self._session.execute(stmt)).scalar_one_or_none()
|
|
469
|
+
return status == DashboardUserStatus.DISABLED.value
|
|
470
|
+
|
|
471
|
+
async def delete(self, key_id: str) -> bool:
|
|
472
|
+
row = await self.get_by_id(key_id)
|
|
473
|
+
if row is None:
|
|
474
|
+
return False
|
|
475
|
+
await self._session.execute(delete(ApiKeyUsageRollup).where(ApiKeyUsageRollup.api_key_id == key_id))
|
|
476
|
+
await self._session.delete(row)
|
|
477
|
+
await self._session.commit()
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
async def commit(self) -> None:
|
|
481
|
+
await self._session.commit()
|
|
482
|
+
|
|
483
|
+
async def rollback(self) -> None:
|
|
484
|
+
await self._session.rollback()
|
|
485
|
+
|
|
486
|
+
# ── Limit operations ──
|
|
487
|
+
|
|
488
|
+
async def get_limits_by_key(self, key_id: str) -> list[ApiKeyLimit]:
|
|
489
|
+
result = await self._session.execute(select(ApiKeyLimit).where(ApiKeyLimit.api_key_id == key_id))
|
|
490
|
+
return list(result.scalars().all())
|
|
491
|
+
|
|
492
|
+
async def replace_limits(self, key_id: str, limits: list[ApiKeyLimit]) -> list[ApiKeyLimit]:
|
|
493
|
+
existing = await self.get_limits_by_key(key_id)
|
|
494
|
+
for limit in existing:
|
|
495
|
+
await self._session.delete(limit)
|
|
496
|
+
for limit in limits:
|
|
497
|
+
limit.api_key_id = key_id
|
|
498
|
+
self._session.add(limit)
|
|
499
|
+
await self._session.commit()
|
|
500
|
+
parent = await self._session.get(ApiKey, key_id)
|
|
501
|
+
if parent is not None:
|
|
502
|
+
await self._session.refresh(parent, attribute_names=["limits"])
|
|
503
|
+
return await self.get_limits_by_key(key_id)
|
|
504
|
+
|
|
505
|
+
async def upsert_limits(
|
|
506
|
+
self,
|
|
507
|
+
key_id: str,
|
|
508
|
+
limits: list[ApiKeyLimit],
|
|
509
|
+
*,
|
|
510
|
+
commit: bool = True,
|
|
511
|
+
preserve_matched_usage: bool = False,
|
|
512
|
+
) -> list[ApiKeyLimit]:
|
|
513
|
+
existing = await self.get_limits_by_key(key_id)
|
|
514
|
+
existing_by_key = {_limit_key(limit): limit for limit in existing}
|
|
515
|
+
incoming_keys = {_limit_key(incoming) for incoming in limits}
|
|
516
|
+
|
|
517
|
+
for incoming in limits:
|
|
518
|
+
key = _limit_key(incoming)
|
|
519
|
+
matched = existing_by_key.get(key)
|
|
520
|
+
if matched is None:
|
|
521
|
+
incoming.api_key_id = key_id
|
|
522
|
+
self._session.add(incoming)
|
|
523
|
+
continue
|
|
524
|
+
matched.max_value = incoming.max_value
|
|
525
|
+
if not preserve_matched_usage:
|
|
526
|
+
matched.current_value = incoming.current_value
|
|
527
|
+
matched.reset_at = incoming.reset_at
|
|
528
|
+
|
|
529
|
+
for old_limit in existing:
|
|
530
|
+
if _limit_key(old_limit) not in incoming_keys:
|
|
531
|
+
await self._session.delete(old_limit)
|
|
532
|
+
|
|
533
|
+
if commit:
|
|
534
|
+
await self._session.commit()
|
|
535
|
+
parent = await self._session.get(ApiKey, key_id)
|
|
536
|
+
if parent is not None:
|
|
537
|
+
await self._session.refresh(parent, attribute_names=["limits"])
|
|
538
|
+
return await self.get_limits_by_key(key_id)
|
|
539
|
+
|
|
540
|
+
async def replace_account_assignments(self, key_id: str, account_ids: list[str], *, commit: bool = True) -> None:
|
|
541
|
+
# Re-check the pending-deletion marker atomically with the write:
|
|
542
|
+
# validation ran in an earlier transaction, and an account DELETE can
|
|
543
|
+
# commit in between — the marked row still exists (background drain),
|
|
544
|
+
# so a plain FK insert would succeed and resurrect an assignment
|
|
545
|
+
# begin_delete just removed. The FOR SHARE lock (PostgreSQL)
|
|
546
|
+
# conflicts with begin_delete's row update, so either this
|
|
547
|
+
# transaction commits first (and begin_delete's assignment cleanup
|
|
548
|
+
# removes its rows) or the marker is visible below and the account is
|
|
549
|
+
# skipped. The account locks are taken BEFORE the assignment-row
|
|
550
|
+
# delete to match begin_delete's order (account row, then assignment
|
|
551
|
+
# rows) — taking them after would form a lock cycle with a
|
|
552
|
+
# concurrent begin_delete and deadlock. SQLite serializes writers,
|
|
553
|
+
# so the marker predicate alone is race-free there.
|
|
554
|
+
if account_ids and self._session.get_bind().dialect.name == "postgresql":
|
|
555
|
+
await self._session.execute(
|
|
556
|
+
select(Account.id).where(Account.id.in_(account_ids)).with_for_update(read=True)
|
|
557
|
+
)
|
|
558
|
+
await self._session.execute(delete(ApiKeyAccountAssignment).where(ApiKeyAccountAssignment.api_key_id == key_id))
|
|
559
|
+
if account_ids:
|
|
560
|
+
assignment_source = (
|
|
561
|
+
select(literal(key_id), Account.id)
|
|
562
|
+
.where(Account.id.in_(account_ids))
|
|
563
|
+
.where(Account.delete_requested_at.is_(None))
|
|
564
|
+
)
|
|
565
|
+
await self._session.execute(
|
|
566
|
+
insert(ApiKeyAccountAssignment).from_select(["api_key_id", "account_id"], assignment_source)
|
|
567
|
+
)
|
|
568
|
+
if commit:
|
|
569
|
+
await self._session.commit()
|
|
570
|
+
parent = await self._session.get(ApiKey, key_id)
|
|
571
|
+
if parent is not None:
|
|
572
|
+
await self._session.refresh(parent, attribute_names=["account_assignments"])
|
|
573
|
+
|
|
574
|
+
async def replace_source_assignments(self, key_id: str, source_ids: list[str], *, commit: bool = True) -> None:
|
|
575
|
+
await self._session.execute(
|
|
576
|
+
delete(ApiKeyModelSourceAssignment).where(ApiKeyModelSourceAssignment.api_key_id == key_id)
|
|
577
|
+
)
|
|
578
|
+
for source_id in source_ids:
|
|
579
|
+
self._session.add(ApiKeyModelSourceAssignment(api_key_id=key_id, source_id=source_id))
|
|
580
|
+
if commit:
|
|
581
|
+
await self._session.commit()
|
|
582
|
+
parent = await self._session.get(ApiKey, key_id)
|
|
583
|
+
if parent is not None:
|
|
584
|
+
await self._session.refresh(parent, attribute_names=["source_assignments"])
|
|
585
|
+
|
|
586
|
+
async def increment_limit_usage(
|
|
587
|
+
self,
|
|
588
|
+
key_id: str,
|
|
589
|
+
*,
|
|
590
|
+
model: str,
|
|
591
|
+
input_tokens: int,
|
|
592
|
+
output_tokens: int,
|
|
593
|
+
cost_microdollars: int,
|
|
594
|
+
) -> None:
|
|
595
|
+
limits = await self.get_limits_by_key(key_id)
|
|
596
|
+
for limit in limits:
|
|
597
|
+
if limit.model_filter is not None and limit.model_filter != model:
|
|
598
|
+
continue
|
|
599
|
+
increment = _compute_increment(limit, input_tokens, output_tokens, cost_microdollars)
|
|
600
|
+
if increment > 0:
|
|
601
|
+
await self._session.execute(
|
|
602
|
+
update(ApiKeyLimit)
|
|
603
|
+
.where(ApiKeyLimit.id == limit.id)
|
|
604
|
+
.values(current_value=ApiKeyLimit.current_value + increment)
|
|
605
|
+
)
|
|
606
|
+
await self._session.commit()
|
|
607
|
+
|
|
608
|
+
async def reset_limit(self, limit_id: int, *, expected_reset_at: datetime, new_reset_at: datetime) -> bool:
|
|
609
|
+
result = await self._session.execute(
|
|
610
|
+
update(ApiKeyLimit)
|
|
611
|
+
.where(ApiKeyLimit.id == limit_id)
|
|
612
|
+
.where(ApiKeyLimit.reset_at == expected_reset_at)
|
|
613
|
+
.values(current_value=0, reset_at=new_reset_at)
|
|
614
|
+
.returning(ApiKeyLimit.id)
|
|
615
|
+
)
|
|
616
|
+
await self._session.commit()
|
|
617
|
+
return result.scalar_one_or_none() is not None
|
|
618
|
+
|
|
619
|
+
async def reset_expired_limits(self, *, now: datetime) -> int:
|
|
620
|
+
reset_count = 0
|
|
621
|
+
# Advance past guarded misses too: legacy SQLite timestamp text can
|
|
622
|
+
# parse to the same datetime without matching its bound representation.
|
|
623
|
+
last_id = 0
|
|
624
|
+
while True:
|
|
625
|
+
result = await self._session.execute(
|
|
626
|
+
select(
|
|
627
|
+
ApiKeyLimit.id,
|
|
628
|
+
ApiKeyLimit.reset_at,
|
|
629
|
+
ApiKeyLimit.limit_window,
|
|
630
|
+
)
|
|
631
|
+
.where(ApiKeyLimit.reset_at < now)
|
|
632
|
+
.where(ApiKeyLimit.id > last_id)
|
|
633
|
+
.order_by(ApiKeyLimit.id.asc())
|
|
634
|
+
.limit(_EXPIRED_LIMIT_RESET_BATCH_SIZE)
|
|
635
|
+
)
|
|
636
|
+
expired_limits = result.all()
|
|
637
|
+
if not expired_limits:
|
|
638
|
+
return reset_count
|
|
639
|
+
last_id = expired_limits[-1].id
|
|
640
|
+
|
|
641
|
+
for limit in expired_limits:
|
|
642
|
+
update_result = await self._session.execute(
|
|
643
|
+
update(ApiKeyLimit)
|
|
644
|
+
.where(ApiKeyLimit.id == limit.id)
|
|
645
|
+
.where(ApiKeyLimit.reset_at == limit.reset_at)
|
|
646
|
+
.values(
|
|
647
|
+
current_value=0,
|
|
648
|
+
reset_at=advance_limit_reset(limit.reset_at, now, limit.limit_window),
|
|
649
|
+
)
|
|
650
|
+
.returning(ApiKeyLimit.id)
|
|
651
|
+
)
|
|
652
|
+
if update_result.scalar_one_or_none() is not None:
|
|
653
|
+
reset_count += 1
|
|
654
|
+
await self._session.commit()
|
|
655
|
+
|
|
656
|
+
async def try_reserve_usage(
|
|
657
|
+
self,
|
|
658
|
+
limit_id: int,
|
|
659
|
+
*,
|
|
660
|
+
delta: int,
|
|
661
|
+
expected_reset_at: datetime,
|
|
662
|
+
) -> ReservationResult:
|
|
663
|
+
if delta <= 0:
|
|
664
|
+
snapshot = await self._session.get(ApiKeyLimit, limit_id)
|
|
665
|
+
return ReservationResult(
|
|
666
|
+
success=True,
|
|
667
|
+
limit_id=limit_id,
|
|
668
|
+
current_value=snapshot.current_value if snapshot is not None else None,
|
|
669
|
+
max_value=snapshot.max_value if snapshot is not None else None,
|
|
670
|
+
reset_at=snapshot.reset_at if snapshot is not None else None,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
result = await self._session.execute(
|
|
674
|
+
update(ApiKeyLimit)
|
|
675
|
+
.where(ApiKeyLimit.id == limit_id)
|
|
676
|
+
.where(ApiKeyLimit.reset_at == expected_reset_at)
|
|
677
|
+
.where(ApiKeyLimit.current_value + delta <= ApiKeyLimit.max_value)
|
|
678
|
+
.values(current_value=ApiKeyLimit.current_value + delta)
|
|
679
|
+
.returning(
|
|
680
|
+
ApiKeyLimit.id,
|
|
681
|
+
ApiKeyLimit.current_value,
|
|
682
|
+
ApiKeyLimit.max_value,
|
|
683
|
+
ApiKeyLimit.reset_at,
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
row = result.first()
|
|
687
|
+
if row is not None:
|
|
688
|
+
return ReservationResult(
|
|
689
|
+
success=True,
|
|
690
|
+
limit_id=int(row.id),
|
|
691
|
+
current_value=int(row.current_value),
|
|
692
|
+
max_value=int(row.max_value),
|
|
693
|
+
reset_at=row.reset_at,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
snapshot_result = await self._session.execute(
|
|
697
|
+
select(
|
|
698
|
+
ApiKeyLimit.current_value,
|
|
699
|
+
ApiKeyLimit.max_value,
|
|
700
|
+
ApiKeyLimit.reset_at,
|
|
701
|
+
).where(ApiKeyLimit.id == limit_id)
|
|
702
|
+
)
|
|
703
|
+
snapshot = snapshot_result.first()
|
|
704
|
+
return ReservationResult(
|
|
705
|
+
success=False,
|
|
706
|
+
limit_id=limit_id,
|
|
707
|
+
current_value=int(snapshot.current_value) if snapshot is not None else None,
|
|
708
|
+
max_value=int(snapshot.max_value) if snapshot is not None else None,
|
|
709
|
+
reset_at=snapshot.reset_at if snapshot is not None else None,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
async def adjust_reserved_usage(
|
|
713
|
+
self,
|
|
714
|
+
limit_id: int,
|
|
715
|
+
*,
|
|
716
|
+
delta: int,
|
|
717
|
+
expected_reset_at: datetime,
|
|
718
|
+
) -> bool:
|
|
719
|
+
stmt = update(ApiKeyLimit).where(ApiKeyLimit.id == limit_id).where(ApiKeyLimit.reset_at == expected_reset_at)
|
|
720
|
+
if delta < 0:
|
|
721
|
+
stmt = stmt.where(ApiKeyLimit.current_value >= -delta)
|
|
722
|
+
result = await self._session.execute(
|
|
723
|
+
stmt.values(current_value=ApiKeyLimit.current_value + delta).returning(ApiKeyLimit.id)
|
|
724
|
+
)
|
|
725
|
+
return result.scalar_one_or_none() is not None
|
|
726
|
+
|
|
727
|
+
async def create_usage_reservation(
|
|
728
|
+
self,
|
|
729
|
+
reservation_id: str,
|
|
730
|
+
*,
|
|
731
|
+
key_id: str,
|
|
732
|
+
model: str,
|
|
733
|
+
items: list[UsageReservationItemData],
|
|
734
|
+
) -> None:
|
|
735
|
+
# Reservation accounting keeps full commit durability. On external/HA
|
|
736
|
+
# PostgreSQL a server failover does not kill in-flight application
|
|
737
|
+
# requests, so an acked-but-lost commit here would desynchronize the
|
|
738
|
+
# reservation ledger from requests that still complete (settlement
|
|
739
|
+
# invariant).
|
|
740
|
+
reservation = ApiKeyUsageReservation(
|
|
741
|
+
id=reservation_id,
|
|
742
|
+
api_key_id=key_id,
|
|
743
|
+
model=model,
|
|
744
|
+
status="reserved",
|
|
745
|
+
)
|
|
746
|
+
self._session.add(reservation)
|
|
747
|
+
for item in items:
|
|
748
|
+
self._session.add(
|
|
749
|
+
ApiKeyUsageReservationItem(
|
|
750
|
+
reservation_id=reservation_id,
|
|
751
|
+
limit_id=item.limit_id,
|
|
752
|
+
limit_type=item.limit_type.value,
|
|
753
|
+
reserved_delta=item.reserved_delta,
|
|
754
|
+
expected_reset_at=item.expected_reset_at,
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
async def get_usage_reservation(self, reservation_id: str) -> UsageReservationData | None:
|
|
759
|
+
result = await self._session.execute(
|
|
760
|
+
select(ApiKeyUsageReservation)
|
|
761
|
+
.options(selectinload(ApiKeyUsageReservation.items))
|
|
762
|
+
.where(ApiKeyUsageReservation.id == reservation_id)
|
|
763
|
+
)
|
|
764
|
+
row = result.scalar_one_or_none()
|
|
765
|
+
if row is None:
|
|
766
|
+
return None
|
|
767
|
+
return UsageReservationData(
|
|
768
|
+
reservation_id=row.id,
|
|
769
|
+
api_key_id=row.api_key_id,
|
|
770
|
+
model=row.model,
|
|
771
|
+
status=row.status,
|
|
772
|
+
items=[
|
|
773
|
+
UsageReservationItemData(
|
|
774
|
+
limit_id=item.limit_id,
|
|
775
|
+
limit_type=LimitType(item.limit_type),
|
|
776
|
+
reserved_delta=item.reserved_delta,
|
|
777
|
+
expected_reset_at=item.expected_reset_at,
|
|
778
|
+
actual_delta=item.actual_delta,
|
|
779
|
+
)
|
|
780
|
+
for item in row.items
|
|
781
|
+
],
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
async def transition_usage_reservation_status(
|
|
785
|
+
self,
|
|
786
|
+
reservation_id: str,
|
|
787
|
+
*,
|
|
788
|
+
expected_status: str,
|
|
789
|
+
new_status: str,
|
|
790
|
+
) -> bool:
|
|
791
|
+
result = await self._session.execute(
|
|
792
|
+
update(ApiKeyUsageReservation)
|
|
793
|
+
.where(ApiKeyUsageReservation.id == reservation_id)
|
|
794
|
+
.where(ApiKeyUsageReservation.status == expected_status)
|
|
795
|
+
.values(status=new_status)
|
|
796
|
+
.returning(ApiKeyUsageReservation.id)
|
|
797
|
+
)
|
|
798
|
+
return result.scalar_one_or_none() is not None
|
|
799
|
+
|
|
800
|
+
async def upsert_reservation_item_actual(
|
|
801
|
+
self,
|
|
802
|
+
reservation_id: str,
|
|
803
|
+
*,
|
|
804
|
+
item: UsageReservationItemData,
|
|
805
|
+
actual_delta: int,
|
|
806
|
+
) -> None:
|
|
807
|
+
bind = self._session.get_bind()
|
|
808
|
+
dialect_name = bind.dialect.name if bind is not None else "sqlite"
|
|
809
|
+
if dialect_name == "sqlite":
|
|
810
|
+
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
|
811
|
+
|
|
812
|
+
stmt = sqlite_insert(ApiKeyUsageReservationItem).values(
|
|
813
|
+
reservation_id=reservation_id,
|
|
814
|
+
limit_id=item.limit_id,
|
|
815
|
+
limit_type=item.limit_type.value,
|
|
816
|
+
reserved_delta=item.reserved_delta,
|
|
817
|
+
expected_reset_at=item.expected_reset_at,
|
|
818
|
+
actual_delta=actual_delta,
|
|
819
|
+
)
|
|
820
|
+
stmt = stmt.on_conflict_do_update(
|
|
821
|
+
index_elements=[
|
|
822
|
+
ApiKeyUsageReservationItem.reservation_id,
|
|
823
|
+
ApiKeyUsageReservationItem.limit_id,
|
|
824
|
+
],
|
|
825
|
+
set_={
|
|
826
|
+
"actual_delta": actual_delta,
|
|
827
|
+
"updated_at": utcnow(),
|
|
828
|
+
},
|
|
829
|
+
)
|
|
830
|
+
await self._session.execute(stmt)
|
|
831
|
+
return
|
|
832
|
+
if dialect_name == "postgresql":
|
|
833
|
+
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
|
834
|
+
|
|
835
|
+
stmt = postgresql_insert(ApiKeyUsageReservationItem).values(
|
|
836
|
+
reservation_id=reservation_id,
|
|
837
|
+
limit_id=item.limit_id,
|
|
838
|
+
limit_type=item.limit_type.value,
|
|
839
|
+
reserved_delta=item.reserved_delta,
|
|
840
|
+
expected_reset_at=item.expected_reset_at,
|
|
841
|
+
actual_delta=actual_delta,
|
|
842
|
+
)
|
|
843
|
+
stmt = stmt.on_conflict_do_update(
|
|
844
|
+
index_elements=[
|
|
845
|
+
ApiKeyUsageReservationItem.reservation_id,
|
|
846
|
+
ApiKeyUsageReservationItem.limit_id,
|
|
847
|
+
],
|
|
848
|
+
set_={
|
|
849
|
+
"actual_delta": actual_delta,
|
|
850
|
+
"updated_at": utcnow(),
|
|
851
|
+
},
|
|
852
|
+
)
|
|
853
|
+
await self._session.execute(stmt)
|
|
854
|
+
return
|
|
855
|
+
await self._session.execute(
|
|
856
|
+
update(ApiKeyUsageReservationItem)
|
|
857
|
+
.where(ApiKeyUsageReservationItem.reservation_id == reservation_id)
|
|
858
|
+
.where(ApiKeyUsageReservationItem.limit_id == item.limit_id)
|
|
859
|
+
.values(actual_delta=actual_delta)
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
async def settle_usage_reservation(
|
|
863
|
+
self,
|
|
864
|
+
reservation_id: str,
|
|
865
|
+
*,
|
|
866
|
+
status: str,
|
|
867
|
+
input_tokens: int | None,
|
|
868
|
+
output_tokens: int | None,
|
|
869
|
+
cached_input_tokens: int | None,
|
|
870
|
+
cost_microdollars: int | None,
|
|
871
|
+
) -> None:
|
|
872
|
+
# Reservation accounting keeps full commit durability. Settlement
|
|
873
|
+
# (finalize/fail/release) is what puts completed-request usage on the
|
|
874
|
+
# books: on external/HA PostgreSQL a failover does not kill the
|
|
875
|
+
# application request, so an acked-but-lost settlement commit would
|
|
876
|
+
# leave the reservation "reserved" until the stale-release scheduler
|
|
877
|
+
# reverses the counters and records zero actual usage — dropping a
|
|
878
|
+
# completed request from token/cost/rate-limit accounting.
|
|
879
|
+
await self._session.execute(
|
|
880
|
+
update(ApiKeyUsageReservation)
|
|
881
|
+
.where(ApiKeyUsageReservation.id == reservation_id)
|
|
882
|
+
.values(
|
|
883
|
+
status=status,
|
|
884
|
+
input_tokens=input_tokens,
|
|
885
|
+
output_tokens=output_tokens,
|
|
886
|
+
cached_input_tokens=cached_input_tokens,
|
|
887
|
+
cost_microdollars=cost_microdollars,
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
async def touch_usage_reservation(self, reservation_id: str) -> bool:
|
|
892
|
+
result = await self._session.execute(
|
|
893
|
+
update(ApiKeyUsageReservation)
|
|
894
|
+
.where(ApiKeyUsageReservation.id == reservation_id)
|
|
895
|
+
.where(ApiKeyUsageReservation.status == "reserved")
|
|
896
|
+
.values(updated_at=utcnow())
|
|
897
|
+
.returning(ApiKeyUsageReservation.id)
|
|
898
|
+
)
|
|
899
|
+
return result.scalar_one_or_none() is not None
|
|
900
|
+
|
|
901
|
+
async def release_stale_usage_reservations(
|
|
902
|
+
self,
|
|
903
|
+
*,
|
|
904
|
+
cutoff: datetime,
|
|
905
|
+
max_age_cutoff: datetime | None = None,
|
|
906
|
+
batch_size: int = _STALE_USAGE_RESERVATION_RELEASE_BATCH_SIZE,
|
|
907
|
+
) -> int:
|
|
908
|
+
released_count = 0
|
|
909
|
+
|
|
910
|
+
# ``cutoff`` reclaims reservations whose heartbeat stopped refreshing
|
|
911
|
+
# ``updated_at``. ``max_age_cutoff`` is the backstop for orphaned
|
|
912
|
+
# heartbeats (issue #1594): a leaked heartbeat task keeps touching
|
|
913
|
+
# ``updated_at`` forever, so reservations older than this hard ceiling
|
|
914
|
+
# on ``created_at`` are reclaimed regardless of heartbeat activity.
|
|
915
|
+
def _stale_clause(query: Any) -> Any:
|
|
916
|
+
stale = ApiKeyUsageReservation.updated_at < cutoff
|
|
917
|
+
if max_age_cutoff is not None:
|
|
918
|
+
stale = or_(stale, ApiKeyUsageReservation.created_at < max_age_cutoff)
|
|
919
|
+
return query.where(stale)
|
|
920
|
+
|
|
921
|
+
try:
|
|
922
|
+
while True:
|
|
923
|
+
async with sqlite_writer_section():
|
|
924
|
+
result = await self._session.execute(
|
|
925
|
+
_stale_clause(
|
|
926
|
+
select(ApiKeyUsageReservation.id).where(ApiKeyUsageReservation.status == "reserved")
|
|
927
|
+
)
|
|
928
|
+
.order_by(ApiKeyUsageReservation.updated_at.asc())
|
|
929
|
+
.limit(batch_size)
|
|
930
|
+
)
|
|
931
|
+
reservation_ids = list(result.scalars().all())
|
|
932
|
+
if not reservation_ids:
|
|
933
|
+
break
|
|
934
|
+
|
|
935
|
+
# Reservation accounting keeps full commit durability:
|
|
936
|
+
# each batch flips reservation status and reverses limit
|
|
937
|
+
# counters, mutating the same ledger as the request-path
|
|
938
|
+
# settlement, so its durability must not depend on which
|
|
939
|
+
# path settles the row. On external/HA PostgreSQL an
|
|
940
|
+
# acked-but-lost batch commit silently reverts rows the
|
|
941
|
+
# scheduler already reported as released.
|
|
942
|
+
item_result = await self._session.execute(
|
|
943
|
+
select(
|
|
944
|
+
ApiKeyUsageReservationItem.reservation_id,
|
|
945
|
+
ApiKeyUsageReservationItem.limit_id,
|
|
946
|
+
ApiKeyUsageReservationItem.limit_type,
|
|
947
|
+
ApiKeyUsageReservationItem.reserved_delta,
|
|
948
|
+
ApiKeyUsageReservationItem.expected_reset_at,
|
|
949
|
+
ApiKeyUsageReservationItem.actual_delta,
|
|
950
|
+
).where(ApiKeyUsageReservationItem.reservation_id.in_(reservation_ids))
|
|
951
|
+
)
|
|
952
|
+
items_by_reservation_id: dict[str, list[UsageReservationItemData]] = {
|
|
953
|
+
reservation_id: [] for reservation_id in reservation_ids
|
|
954
|
+
}
|
|
955
|
+
for item in item_result.all():
|
|
956
|
+
items_by_reservation_id[item.reservation_id].append(
|
|
957
|
+
UsageReservationItemData(
|
|
958
|
+
limit_id=item.limit_id,
|
|
959
|
+
limit_type=LimitType(item.limit_type),
|
|
960
|
+
reserved_delta=item.reserved_delta,
|
|
961
|
+
expected_reset_at=item.expected_reset_at,
|
|
962
|
+
actual_delta=item.actual_delta,
|
|
963
|
+
)
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
for reservation_id in reservation_ids:
|
|
967
|
+
claimed = await self._session.execute(
|
|
968
|
+
_stale_clause(
|
|
969
|
+
update(ApiKeyUsageReservation)
|
|
970
|
+
.where(ApiKeyUsageReservation.id == reservation_id)
|
|
971
|
+
.where(ApiKeyUsageReservation.status == "reserved")
|
|
972
|
+
)
|
|
973
|
+
.values(
|
|
974
|
+
status="released",
|
|
975
|
+
input_tokens=None,
|
|
976
|
+
output_tokens=None,
|
|
977
|
+
cached_input_tokens=None,
|
|
978
|
+
cost_microdollars=None,
|
|
979
|
+
)
|
|
980
|
+
.returning(ApiKeyUsageReservation.id)
|
|
981
|
+
)
|
|
982
|
+
if claimed.scalar_one_or_none() is None:
|
|
983
|
+
continue
|
|
984
|
+
|
|
985
|
+
for item in items_by_reservation_id[reservation_id]:
|
|
986
|
+
await self.adjust_reserved_usage(
|
|
987
|
+
item.limit_id,
|
|
988
|
+
delta=-item.reserved_delta,
|
|
989
|
+
expected_reset_at=item.expected_reset_at,
|
|
990
|
+
)
|
|
991
|
+
await self.upsert_reservation_item_actual(
|
|
992
|
+
reservation_id,
|
|
993
|
+
item=UsageReservationItemData(
|
|
994
|
+
limit_id=item.limit_id,
|
|
995
|
+
limit_type=item.limit_type,
|
|
996
|
+
reserved_delta=item.reserved_delta,
|
|
997
|
+
expected_reset_at=item.expected_reset_at,
|
|
998
|
+
actual_delta=item.actual_delta,
|
|
999
|
+
),
|
|
1000
|
+
actual_delta=0,
|
|
1001
|
+
)
|
|
1002
|
+
released_count += 1
|
|
1003
|
+
await self._session.commit()
|
|
1004
|
+
except Exception:
|
|
1005
|
+
await self._session.rollback()
|
|
1006
|
+
raise
|
|
1007
|
+
|
|
1008
|
+
return released_count
|
|
1009
|
+
|
|
1010
|
+
async def usage_7d_by_account(
|
|
1011
|
+
self,
|
|
1012
|
+
key_id: str,
|
|
1013
|
+
since: datetime,
|
|
1014
|
+
until: datetime,
|
|
1015
|
+
) -> list[ApiKeyAccountCost]:
|
|
1016
|
+
deleted_expr = func.coalesce(RequestLog.deleted_at.is_not(None), False)
|
|
1017
|
+
stmt = (
|
|
1018
|
+
select(
|
|
1019
|
+
RequestLog.account_id,
|
|
1020
|
+
Account.email,
|
|
1021
|
+
deleted_expr.label("is_deleted"),
|
|
1022
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0).label("cost_usd"),
|
|
1023
|
+
)
|
|
1024
|
+
.outerjoin(Account, Account.id == RequestLog.account_id)
|
|
1025
|
+
.where(
|
|
1026
|
+
RequestLog.api_key_id == key_id,
|
|
1027
|
+
RequestLog.requested_at >= since,
|
|
1028
|
+
RequestLog.requested_at < until,
|
|
1029
|
+
self._exclude_warmup_clause(),
|
|
1030
|
+
)
|
|
1031
|
+
.group_by(RequestLog.account_id, Account.email, deleted_expr)
|
|
1032
|
+
)
|
|
1033
|
+
result = await self._session.execute(stmt)
|
|
1034
|
+
return self._build_account_costs(result.all())
|
|
1035
|
+
|
|
1036
|
+
async def trends_by_key(
|
|
1037
|
+
self,
|
|
1038
|
+
key_id: str,
|
|
1039
|
+
since: datetime,
|
|
1040
|
+
until: datetime,
|
|
1041
|
+
bucket_seconds: int = 3600,
|
|
1042
|
+
) -> list[ApiKeyTrendBucket]:
|
|
1043
|
+
# Folded history from the hourly rollups (the api_key_id dimension
|
|
1044
|
+
# and the output-or-reasoning measure were folded for exactly this
|
|
1045
|
+
# read); raw only covers the un-folded complement. Non-hour-multiple
|
|
1046
|
+
# bucket sizes degrade to the full raw scan.
|
|
1047
|
+
merged: dict[int, list[float]] = {}
|
|
1048
|
+
|
|
1049
|
+
def _add(bucket_epoch: int, input_tokens: int, output_tokens: int, cost_usd: float) -> None:
|
|
1050
|
+
entry = merged.setdefault(bucket_epoch, [0, 0, 0.0])
|
|
1051
|
+
entry[0] += input_tokens
|
|
1052
|
+
entry[1] += output_tokens
|
|
1053
|
+
entry[2] += cost_usd
|
|
1054
|
+
|
|
1055
|
+
raw_windows: list[RawWindow] = [(since, until)]
|
|
1056
|
+
if bucket_seconds > 0 and bucket_seconds % HOURLY_BUCKET_SECONDS == 0:
|
|
1057
|
+
rollup_rows, raw_windows = await read_hourly_window(
|
|
1058
|
+
self._session,
|
|
1059
|
+
since,
|
|
1060
|
+
until,
|
|
1061
|
+
filters=(
|
|
1062
|
+
RequestUsageHourlyRollup.api_key_id == to_dimension(key_id),
|
|
1063
|
+
RequestUsageHourlyRollup.request_kind.not_in(WARMUP_REQUEST_KINDS),
|
|
1064
|
+
),
|
|
1065
|
+
)
|
|
1066
|
+
for rollup in rollup_rows:
|
|
1067
|
+
_add(
|
|
1068
|
+
rollup.bucket_epoch // bucket_seconds * bucket_seconds,
|
|
1069
|
+
rollup.input_tokens,
|
|
1070
|
+
rollup.output_or_reasoning_tokens,
|
|
1071
|
+
rollup.cost_usd,
|
|
1072
|
+
)
|
|
1073
|
+
if raw_windows:
|
|
1074
|
+
bind = self._session.get_bind()
|
|
1075
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
1076
|
+
if dialect == "postgresql":
|
|
1077
|
+
bucket_expr = (
|
|
1078
|
+
func.floor(func.extract("epoch", RequestLog.requested_at) / bucket_seconds) * bucket_seconds
|
|
1079
|
+
)
|
|
1080
|
+
else:
|
|
1081
|
+
epoch_col = cast(func.strftime("%s", RequestLog.requested_at), Integer)
|
|
1082
|
+
bucket_expr = cast(epoch_col / bucket_seconds, Integer) * bucket_seconds
|
|
1083
|
+
bucket_col = bucket_expr.label("bucket_epoch")
|
|
1084
|
+
|
|
1085
|
+
stmt = (
|
|
1086
|
+
select(
|
|
1087
|
+
bucket_col,
|
|
1088
|
+
func.coalesce(func.sum(RequestLog.input_tokens), 0).label("total_input_tokens"),
|
|
1089
|
+
func.coalesce(
|
|
1090
|
+
func.sum(func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)),
|
|
1091
|
+
0,
|
|
1092
|
+
).label("total_output_tokens"),
|
|
1093
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0).label("total_cost_usd"),
|
|
1094
|
+
)
|
|
1095
|
+
.where(
|
|
1096
|
+
RequestLog.api_key_id == key_id,
|
|
1097
|
+
raw_windows_clause(raw_windows),
|
|
1098
|
+
self._exclude_warmup_clause(),
|
|
1099
|
+
)
|
|
1100
|
+
.group_by(bucket_col)
|
|
1101
|
+
)
|
|
1102
|
+
for row in (await self._session.execute(stmt)).all():
|
|
1103
|
+
_add(
|
|
1104
|
+
int(row.bucket_epoch),
|
|
1105
|
+
int(row.total_input_tokens or 0),
|
|
1106
|
+
int(row.total_output_tokens or 0),
|
|
1107
|
+
float(row.total_cost_usd or 0.0),
|
|
1108
|
+
)
|
|
1109
|
+
return [
|
|
1110
|
+
ApiKeyTrendBucket(
|
|
1111
|
+
bucket_epoch=bucket_epoch,
|
|
1112
|
+
total_tokens=int(entry[0] + entry[1]),
|
|
1113
|
+
total_cost_usd=round(float(entry[2]), 6),
|
|
1114
|
+
)
|
|
1115
|
+
for bucket_epoch, entry in sorted(merged.items())
|
|
1116
|
+
]
|
|
1117
|
+
|
|
1118
|
+
async def usage_7d(self, key_id: str, since: datetime, until: datetime) -> ApiKeyUsageTotals:
|
|
1119
|
+
filtered_logs = (
|
|
1120
|
+
select(
|
|
1121
|
+
RequestLog.id.label("id"),
|
|
1122
|
+
RequestLog.account_id.label("account_id"),
|
|
1123
|
+
RequestLog.deleted_at.label("deleted_at"),
|
|
1124
|
+
RequestLog.input_tokens.label("input_tokens"),
|
|
1125
|
+
RequestLog.output_tokens.label("output_tokens"),
|
|
1126
|
+
RequestLog.reasoning_tokens.label("reasoning_tokens"),
|
|
1127
|
+
RequestLog.cached_input_tokens.label("cached_input_tokens"),
|
|
1128
|
+
RequestLog.cost_usd.label("cost_usd"),
|
|
1129
|
+
)
|
|
1130
|
+
.where(
|
|
1131
|
+
RequestLog.api_key_id == key_id,
|
|
1132
|
+
RequestLog.requested_at >= since,
|
|
1133
|
+
RequestLog.requested_at < until,
|
|
1134
|
+
self._exclude_warmup_clause(),
|
|
1135
|
+
)
|
|
1136
|
+
.cte("filtered_logs")
|
|
1137
|
+
)
|
|
1138
|
+
usage_totals = select(
|
|
1139
|
+
func.count(filtered_logs.c.id).label("total_requests"),
|
|
1140
|
+
func.coalesce(func.sum(filtered_logs.c.input_tokens), 0).label("total_input_tokens"),
|
|
1141
|
+
func.coalesce(
|
|
1142
|
+
func.sum(func.coalesce(filtered_logs.c.output_tokens, filtered_logs.c.reasoning_tokens, 0)),
|
|
1143
|
+
0,
|
|
1144
|
+
).label("total_output_tokens"),
|
|
1145
|
+
func.coalesce(func.sum(filtered_logs.c.cached_input_tokens), 0).label("cached_input_tokens"),
|
|
1146
|
+
func.coalesce(func.sum(filtered_logs.c.cost_usd), 0.0).label("total_cost_usd"),
|
|
1147
|
+
).cte("usage_totals")
|
|
1148
|
+
deleted_expr = func.coalesce(filtered_logs.c.deleted_at.is_not(None), False)
|
|
1149
|
+
usage_grouped = (
|
|
1150
|
+
select(
|
|
1151
|
+
filtered_logs.c.account_id.label("account_id"),
|
|
1152
|
+
Account.email.label("email"),
|
|
1153
|
+
deleted_expr.label("is_deleted"),
|
|
1154
|
+
func.coalesce(func.sum(filtered_logs.c.cost_usd), 0.0).label("cost_usd"),
|
|
1155
|
+
)
|
|
1156
|
+
.select_from(filtered_logs.outerjoin(Account, Account.id == filtered_logs.c.account_id))
|
|
1157
|
+
.group_by(filtered_logs.c.account_id, Account.email, deleted_expr)
|
|
1158
|
+
.cte("usage_grouped")
|
|
1159
|
+
)
|
|
1160
|
+
stmt = select(
|
|
1161
|
+
usage_totals.c.total_requests,
|
|
1162
|
+
usage_totals.c.total_input_tokens,
|
|
1163
|
+
usage_totals.c.total_output_tokens,
|
|
1164
|
+
usage_totals.c.cached_input_tokens,
|
|
1165
|
+
usage_totals.c.total_cost_usd,
|
|
1166
|
+
usage_grouped.c.account_id,
|
|
1167
|
+
usage_grouped.c.email,
|
|
1168
|
+
usage_grouped.c.is_deleted,
|
|
1169
|
+
usage_grouped.c.cost_usd,
|
|
1170
|
+
).select_from(usage_totals.outerjoin(usage_grouped, true()))
|
|
1171
|
+
result = await self._session.execute(stmt)
|
|
1172
|
+
rows = result.all()
|
|
1173
|
+
row = rows[0]
|
|
1174
|
+
input_sum = int(row.total_input_tokens or 0)
|
|
1175
|
+
output_sum = int(row.total_output_tokens or 0)
|
|
1176
|
+
cached_sum = int(row.cached_input_tokens or 0)
|
|
1177
|
+
cached_sum = max(0, min(cached_sum, input_sum))
|
|
1178
|
+
return ApiKeyUsageTotals(
|
|
1179
|
+
total_requests=int(row.total_requests),
|
|
1180
|
+
total_tokens=input_sum + output_sum,
|
|
1181
|
+
cached_input_tokens=cached_sum,
|
|
1182
|
+
total_cost_usd=round(float(row.total_cost_usd or 0.0), 6),
|
|
1183
|
+
account_costs=self._build_account_costs(rows),
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _compute_increment(limit: ApiKeyLimit, input_tokens: int, output_tokens: int, cost_microdollars: int) -> int:
|
|
1188
|
+
if limit.limit_type == LimitType.TOTAL_TOKENS:
|
|
1189
|
+
return input_tokens + output_tokens
|
|
1190
|
+
if limit.limit_type == LimitType.INPUT_TOKENS:
|
|
1191
|
+
return input_tokens
|
|
1192
|
+
if limit.limit_type == LimitType.OUTPUT_TOKENS:
|
|
1193
|
+
return output_tokens
|
|
1194
|
+
if limit.limit_type == LimitType.COST_USD:
|
|
1195
|
+
return cost_microdollars
|
|
1196
|
+
return 0
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _limit_key(limit: ApiKeyLimit) -> tuple[LimitType, LimitWindow, str | None]:
|
|
1200
|
+
return (limit.limit_type, limit.limit_window, limit.model_filter)
|