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,1765 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import cast as typing_cast
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
from sqlalchemy import Integer, String, and_, case, cast, func, insert, or_, select
|
|
11
|
+
from sqlalchemy import exc as sa_exc
|
|
12
|
+
from sqlalchemy.engine import CursorResult
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
14
|
+
from sqlalchemy.orm import InstrumentedAttribute, make_transient_to_detached
|
|
15
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
16
|
+
|
|
17
|
+
from app.core.usage.logs import (
|
|
18
|
+
CANCELLED_STATUS,
|
|
19
|
+
CLIENT_DISCONNECT_ERROR_CODE,
|
|
20
|
+
NON_ERROR_STATUSES,
|
|
21
|
+
RequestLogLike,
|
|
22
|
+
calculated_cost_from_log,
|
|
23
|
+
)
|
|
24
|
+
from app.core.usage.types import (
|
|
25
|
+
BucketConversationAggregate,
|
|
26
|
+
BucketModelAggregate,
|
|
27
|
+
RequestActivityAggregate,
|
|
28
|
+
UsageSummaryLogsAggregate,
|
|
29
|
+
)
|
|
30
|
+
from app.core.utils.request_id import ensure_request_id
|
|
31
|
+
from app.core.utils.time import utcnow
|
|
32
|
+
from app.db.models import (
|
|
33
|
+
Account,
|
|
34
|
+
AccountUsageRollupState,
|
|
35
|
+
ApiKey,
|
|
36
|
+
RequestDemandQuarterRollup,
|
|
37
|
+
RequestKind,
|
|
38
|
+
RequestLog,
|
|
39
|
+
RequestUsageHourlyErrorRollup,
|
|
40
|
+
RequestUsageHourlyRollup,
|
|
41
|
+
)
|
|
42
|
+
from app.db.session import relax_commit_durability, sqlite_writer_section
|
|
43
|
+
from app.modules.accounts.usage_rollup import lock_fold_state
|
|
44
|
+
from app.modules.accounts.usage_time_rollup import (
|
|
45
|
+
HOURLY_BUCKET_SECONDS,
|
|
46
|
+
WARMUP_REQUEST_KINDS,
|
|
47
|
+
conversation_id_expr,
|
|
48
|
+
floor_to_hour,
|
|
49
|
+
from_dimension,
|
|
50
|
+
to_dimension,
|
|
51
|
+
)
|
|
52
|
+
from app.modules.accounts.usage_time_rollup_read import (
|
|
53
|
+
RawWindow,
|
|
54
|
+
conversation_presence_union,
|
|
55
|
+
earliest_hourly_bucket_at,
|
|
56
|
+
raw_windows_clause,
|
|
57
|
+
read_errors_window,
|
|
58
|
+
read_hourly_window,
|
|
59
|
+
sum_demand_window,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class _RequestLogFilters:
|
|
65
|
+
conditions: list
|
|
66
|
+
needs_related_search_joins: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Earliest representable listing lower bound for the rollup-count window.
|
|
70
|
+
_ROLLUP_EPOCH = datetime(1970, 1, 1)
|
|
71
|
+
|
|
72
|
+
# Column keys for the Core request-log insert in ``add_log``. Every non-PK
|
|
73
|
+
# column is read off the fully built transient instance; columns ``add_log``
|
|
74
|
+
# never sets are nullable with no Python/server default the flush would have
|
|
75
|
+
# applied, so an explicit NULL is identical to the old unit-of-work insert.
|
|
76
|
+
_REQUEST_LOG_INSERT_COLUMN_KEYS: tuple[str, ...] = tuple(
|
|
77
|
+
column.key for column in RequestLog.__table__.columns if column.key != "id"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _naive_utc(value: datetime) -> datetime:
|
|
82
|
+
"""FastAPI parses ISO `Z` query bounds as offset-aware datetimes;
|
|
83
|
+
``requested_at`` and the rollup grid are naive UTC, so normalize before
|
|
84
|
+
any window arithmetic (the SQL filter comparisons already coerce)."""
|
|
85
|
+
if value.tzinfo is None:
|
|
86
|
+
return value
|
|
87
|
+
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class _DemandCountParams:
|
|
92
|
+
"""Listing filters that map losslessly onto demand-rollup dimensions.
|
|
93
|
+
|
|
94
|
+
Built only when the listing carries no free-text search and no
|
|
95
|
+
error-code splits — the demand grain has no error_code dimension, and
|
|
96
|
+
search reaches related tables. Everything else (time bounds, accounts,
|
|
97
|
+
api keys, model/effort pairs, statuses, soft-delete exclusion) is a
|
|
98
|
+
demand dimension, so the folded window can be counted from the rollup.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
since: datetime | None
|
|
102
|
+
until: datetime | None
|
|
103
|
+
account_ids: list[str] | None
|
|
104
|
+
api_key_ids: list[str] | None
|
|
105
|
+
model_options: list[tuple[str, str | None]] | None
|
|
106
|
+
models: list[str] | None
|
|
107
|
+
reasoning_efforts: list[str] | None
|
|
108
|
+
include_success: bool
|
|
109
|
+
include_cancelled: bool
|
|
110
|
+
include_error_other: bool
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class RequestLogsResult:
|
|
115
|
+
logs: list[RequestLog]
|
|
116
|
+
total: int
|
|
117
|
+
aggregated_cost_usd: float | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# The exact COUNT(*) behind the request-log listing's "X-Y of N" scans the
|
|
121
|
+
# whole filtered set on PostgreSQL; the dashboard re-runs it on every 30s
|
|
122
|
+
# poll and every pagination click even though the displayed total is
|
|
123
|
+
# tolerant of short staleness. Cache it per filter signature for a small
|
|
124
|
+
# fixed TTL (issue #1340 / PRINCIPLES.md P2; the test suite patches the
|
|
125
|
+
# TTL to 0 so totals stay exact within a test).
|
|
126
|
+
_COUNT_CACHE_TTL_SECONDS = 30.0
|
|
127
|
+
_COUNT_CACHE_MAX_ENTRIES = 256
|
|
128
|
+
_CONVERSATION_WHITESPACE = " \t\n\v\f\r"
|
|
129
|
+
_recent_count_cache: dict[tuple, tuple[int, float]] = {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _escape_like(value: str) -> str:
|
|
133
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _normalize_conversation_id(value: str | None) -> str | None:
|
|
137
|
+
if value is None:
|
|
138
|
+
return None
|
|
139
|
+
normalized = value.strip(_CONVERSATION_WHITESPACE)
|
|
140
|
+
return normalized or None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _clear_recent_count_cache() -> None:
|
|
144
|
+
_recent_count_cache.clear()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cached_recent_count(key: tuple) -> int | None:
|
|
148
|
+
entry = _recent_count_cache.get(key)
|
|
149
|
+
if entry is None:
|
|
150
|
+
return None
|
|
151
|
+
total, expires_at = entry
|
|
152
|
+
if time.monotonic() >= expires_at:
|
|
153
|
+
_recent_count_cache.pop(key, None)
|
|
154
|
+
return None
|
|
155
|
+
return total
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _store_recent_count(key: tuple, total: int, ttl_seconds: float) -> None:
|
|
159
|
+
if len(_recent_count_cache) >= _COUNT_CACHE_MAX_ENTRIES:
|
|
160
|
+
oldest = min(_recent_count_cache, key=lambda existing: _recent_count_cache[existing][1])
|
|
161
|
+
_recent_count_cache.pop(oldest, None)
|
|
162
|
+
_recent_count_cache[key] = (total, time.monotonic() + ttl_seconds)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class PreviousResponseOwnerRecord:
|
|
167
|
+
account_id: str
|
|
168
|
+
requested_at: datetime | None
|
|
169
|
+
session_id: str | None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class ConversationListSummary:
|
|
174
|
+
conversation_id: str
|
|
175
|
+
first_requested_at: datetime
|
|
176
|
+
last_requested_at: datetime
|
|
177
|
+
request_count: int
|
|
178
|
+
account_count: int
|
|
179
|
+
total_tokens: int
|
|
180
|
+
cached_input_tokens: int | None
|
|
181
|
+
cost_usd: float
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class ConversationFacet:
|
|
186
|
+
conversation_id: str
|
|
187
|
+
value: str
|
|
188
|
+
request_count: int
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, slots=True)
|
|
192
|
+
class ConversationListResult:
|
|
193
|
+
summaries: list[ConversationListSummary]
|
|
194
|
+
account_facets: list[ConversationFacet]
|
|
195
|
+
api_key_facets: list[ConversationFacet]
|
|
196
|
+
model_facets: list[ConversationFacet]
|
|
197
|
+
total: int
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True, slots=True)
|
|
201
|
+
class ConversationModelStatRow:
|
|
202
|
+
model: str
|
|
203
|
+
reasoning_effort: str | None
|
|
204
|
+
request_count: int
|
|
205
|
+
total_elapsed_ms: int
|
|
206
|
+
input_tokens: int
|
|
207
|
+
cached_input_tokens: int | None
|
|
208
|
+
output_tokens: int
|
|
209
|
+
cost_usd: float
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True, slots=True)
|
|
213
|
+
class ConversationDetailsResult:
|
|
214
|
+
conversation_id: str
|
|
215
|
+
started_at: datetime
|
|
216
|
+
last_requested_at: datetime
|
|
217
|
+
account_count: int
|
|
218
|
+
total_elapsed_ms: int
|
|
219
|
+
useragent_group: str | None
|
|
220
|
+
model_stats: list[ConversationModelStatRow]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class RequestLogsRepository:
|
|
224
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
225
|
+
self._session = session
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _exclude_warmup_clause() -> ColumnElement[bool]:
|
|
229
|
+
return RequestLog.request_kind.not_in((RequestKind.WARMUP.value, "limit_warmup"))
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _conversation_id_expr() -> ColumnElement:
|
|
233
|
+
return conversation_id_expr()
|
|
234
|
+
|
|
235
|
+
def _conversation_output_expr(self) -> ColumnElement:
|
|
236
|
+
return func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
|
|
237
|
+
|
|
238
|
+
def _conversation_cached_expr(self) -> ColumnElement:
|
|
239
|
+
dialect = self._session.get_bind().dialect.name
|
|
240
|
+
least = func.least if dialect == "postgresql" else func.min
|
|
241
|
+
greatest = func.greatest if dialect == "postgresql" else func.max
|
|
242
|
+
return case(
|
|
243
|
+
(RequestLog.cached_input_tokens.is_(None), None),
|
|
244
|
+
(RequestLog.input_tokens.is_(None), greatest(0, RequestLog.cached_input_tokens)),
|
|
245
|
+
else_=greatest(0, least(RequestLog.cached_input_tokens, RequestLog.input_tokens)),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _eligible_conversation_row_conditions(self) -> list[ColumnElement[bool]]:
|
|
249
|
+
return [
|
|
250
|
+
RequestLog.deleted_at.is_(None),
|
|
251
|
+
self._exclude_warmup_clause(),
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
def _conversation_conditions(self) -> list[ColumnElement[bool]]:
|
|
255
|
+
return [
|
|
256
|
+
*self._eligible_conversation_row_conditions(),
|
|
257
|
+
RequestLog.conversation_id.is_not(None),
|
|
258
|
+
RequestLog.conversation_id != "",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
def _reasoning_effort_sort_key(self) -> list[ColumnElement]:
|
|
262
|
+
rank = case(
|
|
263
|
+
(RequestLog.reasoning_effort.is_(None), 0),
|
|
264
|
+
(RequestLog.reasoning_effort == "", 1),
|
|
265
|
+
else_=2,
|
|
266
|
+
)
|
|
267
|
+
return [rank, func.coalesce(RequestLog.reasoning_effort, "")]
|
|
268
|
+
|
|
269
|
+
async def list_conversations(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
limit: int = 50,
|
|
273
|
+
offset: int = 0,
|
|
274
|
+
search: str | None = None,
|
|
275
|
+
since: datetime | None = None,
|
|
276
|
+
cache_mode: str = "since",
|
|
277
|
+
timeframe: str | None = None,
|
|
278
|
+
) -> ConversationListResult:
|
|
279
|
+
conversation_id = RequestLog.conversation_id
|
|
280
|
+
base_conditions = self._conversation_conditions()
|
|
281
|
+
search_value = search.strip() if search and search.strip() else None
|
|
282
|
+
candidate_conditions = [*base_conditions]
|
|
283
|
+
if since is not None:
|
|
284
|
+
candidate_conditions.append(RequestLog.requested_at >= since)
|
|
285
|
+
if search_value is not None:
|
|
286
|
+
pattern = f"%{_escape_like(search_value)}%"
|
|
287
|
+
candidate_conditions.append(
|
|
288
|
+
or_(
|
|
289
|
+
conversation_id.ilike(pattern, escape="\\"),
|
|
290
|
+
RequestLog.useragent_group.ilike(pattern, escape="\\"),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
if since is not None or search_value is not None:
|
|
294
|
+
candidate_ids = (
|
|
295
|
+
select(conversation_id.label("conversation_id")).where(*candidate_conditions).distinct().subquery()
|
|
296
|
+
)
|
|
297
|
+
conditions = [*base_conditions, conversation_id.in_(select(candidate_ids.c.conversation_id))]
|
|
298
|
+
else:
|
|
299
|
+
conditions = base_conditions
|
|
300
|
+
|
|
301
|
+
output = self._conversation_output_expr()
|
|
302
|
+
cached = self._conversation_cached_expr()
|
|
303
|
+
summary_conditions = [*conditions]
|
|
304
|
+
summary_stmt = (
|
|
305
|
+
select(
|
|
306
|
+
conversation_id.label("conversation_id"),
|
|
307
|
+
func.min(RequestLog.requested_at).label("first_requested_at"),
|
|
308
|
+
func.max(RequestLog.requested_at).label("last_requested_at"),
|
|
309
|
+
func.count().label("request_count"),
|
|
310
|
+
func.count(func.distinct(RequestLog.account_id)).label("account_count"),
|
|
311
|
+
func.coalesce(func.sum(func.coalesce(RequestLog.input_tokens, 0) + output), 0).label("total_tokens"),
|
|
312
|
+
func.sum(cached).label("cached_input_tokens"),
|
|
313
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0).label("cost_usd"),
|
|
314
|
+
)
|
|
315
|
+
.where(*summary_conditions)
|
|
316
|
+
.group_by(conversation_id)
|
|
317
|
+
)
|
|
318
|
+
summary_subquery = summary_stmt.subquery()
|
|
319
|
+
filtered_summary_stmt = select(summary_subquery)
|
|
320
|
+
ttl_seconds = _COUNT_CACHE_TTL_SECONDS
|
|
321
|
+
if ttl_seconds <= 0:
|
|
322
|
+
total = int(
|
|
323
|
+
(
|
|
324
|
+
await self._session.execute(select(func.count()).select_from(filtered_summary_stmt.subquery()))
|
|
325
|
+
).scalar_one()
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
normalized_search = (search.strip() or None) if search else None
|
|
329
|
+
if cache_mode == "timeframe":
|
|
330
|
+
mode_token = ("timeframe", timeframe)
|
|
331
|
+
else:
|
|
332
|
+
mode_token = ("since", since)
|
|
333
|
+
cache_key = ("conversation-count", normalized_search, mode_token)
|
|
334
|
+
total = _cached_recent_count(cache_key)
|
|
335
|
+
if total is None:
|
|
336
|
+
total = int(
|
|
337
|
+
(
|
|
338
|
+
await self._session.execute(select(func.count()).select_from(filtered_summary_stmt.subquery()))
|
|
339
|
+
).scalar_one()
|
|
340
|
+
)
|
|
341
|
+
_store_recent_count(cache_key, total, ttl_seconds)
|
|
342
|
+
page_rows = (
|
|
343
|
+
await self._session.execute(
|
|
344
|
+
filtered_summary_stmt.order_by(
|
|
345
|
+
summary_subquery.c.last_requested_at.desc(), summary_subquery.c.conversation_id.asc()
|
|
346
|
+
)
|
|
347
|
+
.offset(offset)
|
|
348
|
+
.limit(limit)
|
|
349
|
+
)
|
|
350
|
+
).all()
|
|
351
|
+
summaries = [
|
|
352
|
+
ConversationListSummary(
|
|
353
|
+
conversation_id=row.conversation_id,
|
|
354
|
+
first_requested_at=row.first_requested_at,
|
|
355
|
+
last_requested_at=row.last_requested_at,
|
|
356
|
+
request_count=int(row.request_count),
|
|
357
|
+
account_count=int(row.account_count),
|
|
358
|
+
total_tokens=int(row.total_tokens),
|
|
359
|
+
cached_input_tokens=(int(row.cached_input_tokens) if row.cached_input_tokens is not None else None),
|
|
360
|
+
cost_usd=float(row.cost_usd or 0.0),
|
|
361
|
+
)
|
|
362
|
+
for row in page_rows
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
page_ids = [summary.conversation_id for summary in summaries]
|
|
366
|
+
account_facets: list[ConversationFacet] = []
|
|
367
|
+
api_key_facets: list[ConversationFacet] = []
|
|
368
|
+
model_facets: list[ConversationFacet] = []
|
|
369
|
+
if page_ids:
|
|
370
|
+
# Candidate-ID membership already selected conversations with
|
|
371
|
+
# activity in the window. Facets must describe the same full
|
|
372
|
+
# eligible conversation rows as that summary, including history
|
|
373
|
+
# before `since`.
|
|
374
|
+
facet_conditions = [*conditions]
|
|
375
|
+
account_facets = await self._conversation_facets(facet_conditions, page_ids, RequestLog.account_id)
|
|
376
|
+
api_key_facets = await self._conversation_facets(facet_conditions, page_ids, RequestLog.api_key_id)
|
|
377
|
+
model_facets = await self._conversation_facets(facet_conditions, page_ids, RequestLog.model)
|
|
378
|
+
return ConversationListResult(
|
|
379
|
+
summaries=summaries,
|
|
380
|
+
account_facets=account_facets,
|
|
381
|
+
api_key_facets=api_key_facets,
|
|
382
|
+
model_facets=model_facets,
|
|
383
|
+
total=total,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
async def _conversation_facets(
|
|
387
|
+
self,
|
|
388
|
+
conditions: list[ColumnElement[bool]],
|
|
389
|
+
page_ids: list[str],
|
|
390
|
+
value_column: InstrumentedAttribute[str | None] | InstrumentedAttribute[str],
|
|
391
|
+
) -> list[ConversationFacet]:
|
|
392
|
+
conversation_id = RequestLog.conversation_id
|
|
393
|
+
facet_conditions = [*conditions, conversation_id.in_(page_ids), value_column.is_not(None)]
|
|
394
|
+
if getattr(value_column, "key", None) == RequestLog.api_key_id.key:
|
|
395
|
+
facet_conditions.append(value_column.in_(select(ApiKey.id)))
|
|
396
|
+
stmt = (
|
|
397
|
+
select(
|
|
398
|
+
conversation_id.label("conversation_id"),
|
|
399
|
+
value_column.label("value"),
|
|
400
|
+
func.count().label("request_count"),
|
|
401
|
+
)
|
|
402
|
+
.where(*facet_conditions)
|
|
403
|
+
.group_by(conversation_id, value_column)
|
|
404
|
+
.order_by(
|
|
405
|
+
conversation_id.asc(),
|
|
406
|
+
func.count().desc(),
|
|
407
|
+
func.max(RequestLog.requested_at).desc(),
|
|
408
|
+
value_column.asc(),
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
rows = (await self._session.execute(stmt)).all()
|
|
412
|
+
return [
|
|
413
|
+
ConversationFacet(
|
|
414
|
+
conversation_id=row.conversation_id,
|
|
415
|
+
value=row.value,
|
|
416
|
+
request_count=int(row.request_count),
|
|
417
|
+
)
|
|
418
|
+
for row in rows
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
async def get_conversation_details(self, conversation_id: str) -> ConversationDetailsResult | None:
|
|
422
|
+
target = _normalize_conversation_id(conversation_id)
|
|
423
|
+
if not target:
|
|
424
|
+
return None
|
|
425
|
+
normalized_id = RequestLog.conversation_id
|
|
426
|
+
conditions = [*self._conversation_conditions(), normalized_id == target]
|
|
427
|
+
# Details intentionally remain window-agnostic; only list membership uses since.
|
|
428
|
+
summary = (
|
|
429
|
+
await self._session.execute(
|
|
430
|
+
select(
|
|
431
|
+
func.min(RequestLog.requested_at).label("started_at"),
|
|
432
|
+
func.max(RequestLog.requested_at).label("last_requested_at"),
|
|
433
|
+
func.count(func.distinct(RequestLog.account_id)).label("account_count"),
|
|
434
|
+
func.coalesce(func.sum(func.coalesce(RequestLog.latency_ms, 0)), 0).label("total_elapsed_ms"),
|
|
435
|
+
).where(*conditions)
|
|
436
|
+
)
|
|
437
|
+
).one()
|
|
438
|
+
if summary.started_at is None:
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
dominant = (
|
|
442
|
+
await self._session.execute(
|
|
443
|
+
select(RequestLog.useragent_group)
|
|
444
|
+
.where(*conditions, RequestLog.useragent_group.is_not(None))
|
|
445
|
+
.group_by(RequestLog.useragent_group)
|
|
446
|
+
.order_by(
|
|
447
|
+
func.count().desc(),
|
|
448
|
+
func.max(RequestLog.requested_at).desc(),
|
|
449
|
+
RequestLog.useragent_group.asc(),
|
|
450
|
+
)
|
|
451
|
+
.limit(1)
|
|
452
|
+
)
|
|
453
|
+
).scalar_one_or_none()
|
|
454
|
+
|
|
455
|
+
output = self._conversation_output_expr()
|
|
456
|
+
cached = self._conversation_cached_expr()
|
|
457
|
+
model_rows = (
|
|
458
|
+
await self._session.execute(
|
|
459
|
+
select(
|
|
460
|
+
RequestLog.model.label("model"),
|
|
461
|
+
RequestLog.reasoning_effort.label("reasoning_effort"),
|
|
462
|
+
func.count().label("request_count"),
|
|
463
|
+
func.coalesce(func.sum(func.coalesce(RequestLog.latency_ms, 0)), 0).label("total_elapsed_ms"),
|
|
464
|
+
func.coalesce(func.sum(func.coalesce(RequestLog.input_tokens, 0)), 0).label("input_tokens"),
|
|
465
|
+
func.sum(cached).label("cached_input_tokens"),
|
|
466
|
+
func.coalesce(func.sum(output), 0).label("output_tokens"),
|
|
467
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0).label("cost_usd"),
|
|
468
|
+
)
|
|
469
|
+
.where(*conditions)
|
|
470
|
+
.group_by(RequestLog.model, RequestLog.reasoning_effort)
|
|
471
|
+
.order_by(
|
|
472
|
+
func.count().desc(),
|
|
473
|
+
func.max(RequestLog.requested_at).desc(),
|
|
474
|
+
RequestLog.model.asc(),
|
|
475
|
+
*self._reasoning_effort_sort_key(),
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
).all()
|
|
479
|
+
return ConversationDetailsResult(
|
|
480
|
+
conversation_id=target,
|
|
481
|
+
started_at=summary.started_at,
|
|
482
|
+
last_requested_at=summary.last_requested_at,
|
|
483
|
+
account_count=int(summary.account_count),
|
|
484
|
+
total_elapsed_ms=int(summary.total_elapsed_ms),
|
|
485
|
+
useragent_group=dominant,
|
|
486
|
+
model_stats=[
|
|
487
|
+
ConversationModelStatRow(
|
|
488
|
+
model=row.model,
|
|
489
|
+
reasoning_effort=row.reasoning_effort,
|
|
490
|
+
request_count=int(row.request_count),
|
|
491
|
+
total_elapsed_ms=int(row.total_elapsed_ms),
|
|
492
|
+
input_tokens=int(row.input_tokens),
|
|
493
|
+
cached_input_tokens=(int(row.cached_input_tokens) if row.cached_input_tokens is not None else None),
|
|
494
|
+
output_tokens=int(row.output_tokens),
|
|
495
|
+
cost_usd=float(row.cost_usd or 0.0),
|
|
496
|
+
)
|
|
497
|
+
for row in model_rows
|
|
498
|
+
],
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def _bucket_epoch_expr(self, bucket_seconds: int) -> ColumnElement:
|
|
502
|
+
bind = self._session.get_bind()
|
|
503
|
+
dialect = bind.dialect.name if bind else "sqlite"
|
|
504
|
+
if dialect == "postgresql":
|
|
505
|
+
return func.floor(func.extract("epoch", RequestLog.requested_at) / bucket_seconds) * bucket_seconds
|
|
506
|
+
# Use explicit integer division for SQLite: CAST(epoch / N AS INTEGER) * N
|
|
507
|
+
epoch_col = cast(func.strftime("%s", RequestLog.requested_at), Integer)
|
|
508
|
+
return cast(epoch_col / bucket_seconds, Integer) * bucket_seconds
|
|
509
|
+
|
|
510
|
+
async def list_since(self, since: datetime) -> list[RequestLog]:
|
|
511
|
+
result = await self._session.execute(
|
|
512
|
+
select(RequestLog).where(
|
|
513
|
+
RequestLog.requested_at >= since,
|
|
514
|
+
self._exclude_warmup_clause(),
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
return list(result.scalars().all())
|
|
518
|
+
|
|
519
|
+
async def find_latest_owner_record_for_response_id(
|
|
520
|
+
self,
|
|
521
|
+
*,
|
|
522
|
+
response_id: str,
|
|
523
|
+
api_key_id: str | None,
|
|
524
|
+
session_id: str | None = None,
|
|
525
|
+
) -> PreviousResponseOwnerRecord | None:
|
|
526
|
+
response_id_value = response_id.strip()
|
|
527
|
+
if not response_id_value:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
base_conditions = [
|
|
531
|
+
RequestLog.request_id == response_id_value,
|
|
532
|
+
RequestLog.status == "success",
|
|
533
|
+
RequestLog.account_id.is_not(None),
|
|
534
|
+
]
|
|
535
|
+
if api_key_id is not None:
|
|
536
|
+
base_conditions.append(RequestLog.api_key_id == api_key_id)
|
|
537
|
+
|
|
538
|
+
async def _lookup_owner_record(
|
|
539
|
+
conditions: list[ColumnElement[bool]],
|
|
540
|
+
) -> PreviousResponseOwnerRecord | None:
|
|
541
|
+
stmt = (
|
|
542
|
+
select(RequestLog.account_id, RequestLog.requested_at, RequestLog.session_id)
|
|
543
|
+
.where(and_(*conditions))
|
|
544
|
+
.order_by(RequestLog.requested_at.desc(), RequestLog.id.desc())
|
|
545
|
+
.limit(1)
|
|
546
|
+
)
|
|
547
|
+
result = await self._session.execute(stmt)
|
|
548
|
+
row = result.one_or_none()
|
|
549
|
+
if row is None:
|
|
550
|
+
return None
|
|
551
|
+
account_id, requested_at, owner_session_id = row
|
|
552
|
+
if not isinstance(account_id, str):
|
|
553
|
+
return None
|
|
554
|
+
stripped = account_id.strip()
|
|
555
|
+
if not stripped:
|
|
556
|
+
return None
|
|
557
|
+
normalized_owner_session_id = (
|
|
558
|
+
owner_session_id.strip() if isinstance(owner_session_id, str) and owner_session_id.strip() else None
|
|
559
|
+
)
|
|
560
|
+
return PreviousResponseOwnerRecord(
|
|
561
|
+
account_id=stripped,
|
|
562
|
+
requested_at=requested_at if isinstance(requested_at, datetime) else None,
|
|
563
|
+
session_id=normalized_owner_session_id,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
session_id_value = session_id.strip() if isinstance(session_id, str) else ""
|
|
567
|
+
if session_id_value:
|
|
568
|
+
scoped_owner = await _lookup_owner_record([*base_conditions, RequestLog.session_id == session_id_value])
|
|
569
|
+
if scoped_owner is not None:
|
|
570
|
+
return scoped_owner
|
|
571
|
+
|
|
572
|
+
return await _lookup_owner_record(base_conditions)
|
|
573
|
+
|
|
574
|
+
async def find_latest_account_id_for_response_id(
|
|
575
|
+
self,
|
|
576
|
+
*,
|
|
577
|
+
response_id: str,
|
|
578
|
+
api_key_id: str | None,
|
|
579
|
+
session_id: str | None = None,
|
|
580
|
+
) -> str | None:
|
|
581
|
+
owner = await self.find_latest_owner_record_for_response_id(
|
|
582
|
+
response_id=response_id,
|
|
583
|
+
api_key_id=api_key_id,
|
|
584
|
+
session_id=session_id,
|
|
585
|
+
)
|
|
586
|
+
return owner.account_id if owner is not None else None
|
|
587
|
+
|
|
588
|
+
async def aggregate_by_bucket(
|
|
589
|
+
self,
|
|
590
|
+
since: datetime,
|
|
591
|
+
bucket_seconds: int = 21600,
|
|
592
|
+
) -> list[BucketModelAggregate]:
|
|
593
|
+
# Folded history comes from the hourly rollups; only the un-folded
|
|
594
|
+
# complement scans raw request_logs. Every display bucket the
|
|
595
|
+
# dashboard uses (3600/21600/86400) is a whole multiple of the rollup
|
|
596
|
+
# hour, so a folded bucket is never split across the merge; any other
|
|
597
|
+
# granularity degrades to the full raw scan.
|
|
598
|
+
merged: dict[tuple[int, str, str | None], list[float]] = {}
|
|
599
|
+
|
|
600
|
+
def _add(key: tuple[int, str, str | None], values: tuple[int, int, int, int, int, int, int, float]) -> None:
|
|
601
|
+
entry = merged.setdefault(key, [0, 0, 0, 0, 0, 0, 0, 0.0])
|
|
602
|
+
for index, value in enumerate(values):
|
|
603
|
+
entry[index] += value
|
|
604
|
+
|
|
605
|
+
raw_windows: list[RawWindow] = [(since, None)]
|
|
606
|
+
if bucket_seconds > 0 and bucket_seconds % HOURLY_BUCKET_SECONDS == 0:
|
|
607
|
+
rollup_rows, raw_windows = await read_hourly_window(
|
|
608
|
+
self._session,
|
|
609
|
+
since,
|
|
610
|
+
filters=(RequestUsageHourlyRollup.request_kind.not_in(WARMUP_REQUEST_KINDS),),
|
|
611
|
+
)
|
|
612
|
+
for rollup in rollup_rows:
|
|
613
|
+
_add(
|
|
614
|
+
(
|
|
615
|
+
rollup.bucket_epoch // bucket_seconds * bucket_seconds,
|
|
616
|
+
rollup.model,
|
|
617
|
+
from_dimension(rollup.service_tier),
|
|
618
|
+
),
|
|
619
|
+
(
|
|
620
|
+
rollup.request_count,
|
|
621
|
+
rollup.error_count,
|
|
622
|
+
rollup.cancelled_count,
|
|
623
|
+
rollup.input_tokens,
|
|
624
|
+
rollup.output_tokens,
|
|
625
|
+
rollup.cached_input_tokens,
|
|
626
|
+
rollup.reasoning_tokens,
|
|
627
|
+
rollup.cost_usd,
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
if raw_windows:
|
|
631
|
+
bucket_col = self._bucket_epoch_expr(bucket_seconds).label("bucket_epoch")
|
|
632
|
+
stmt = (
|
|
633
|
+
select(
|
|
634
|
+
bucket_col,
|
|
635
|
+
RequestLog.model,
|
|
636
|
+
RequestLog.service_tier,
|
|
637
|
+
func.count().label("request_count"),
|
|
638
|
+
func.sum(cast(RequestLog.status.not_in(NON_ERROR_STATUSES), Integer)).label("error_count"),
|
|
639
|
+
func.sum(cast(RequestLog.status == CANCELLED_STATUS, Integer)).label("cancelled_count"),
|
|
640
|
+
func.coalesce(func.sum(RequestLog.input_tokens), 0).label("input_tokens"),
|
|
641
|
+
func.coalesce(func.sum(RequestLog.output_tokens), 0).label("output_tokens"),
|
|
642
|
+
func.coalesce(func.sum(RequestLog.cached_input_tokens), 0).label("cached_input_tokens"),
|
|
643
|
+
func.coalesce(func.sum(RequestLog.reasoning_tokens), 0).label("reasoning_tokens"),
|
|
644
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0).label("cost_usd"),
|
|
645
|
+
)
|
|
646
|
+
.where(raw_windows_clause(raw_windows))
|
|
647
|
+
.where(self._exclude_warmup_clause())
|
|
648
|
+
.group_by(bucket_col, RequestLog.model, RequestLog.service_tier)
|
|
649
|
+
)
|
|
650
|
+
for row in (await self._session.execute(stmt)).all():
|
|
651
|
+
_add(
|
|
652
|
+
(int(row.bucket_epoch), row.model, row.service_tier),
|
|
653
|
+
(
|
|
654
|
+
int(row.request_count),
|
|
655
|
+
int(row.error_count),
|
|
656
|
+
int(row.cancelled_count),
|
|
657
|
+
int(row.input_tokens),
|
|
658
|
+
int(row.output_tokens),
|
|
659
|
+
int(row.cached_input_tokens),
|
|
660
|
+
int(row.reasoning_tokens),
|
|
661
|
+
float(row.cost_usd or 0.0),
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
return [
|
|
665
|
+
BucketModelAggregate(
|
|
666
|
+
bucket_epoch=key[0],
|
|
667
|
+
model=key[1],
|
|
668
|
+
service_tier=key[2],
|
|
669
|
+
request_count=int(entry[0]),
|
|
670
|
+
error_count=int(entry[1]),
|
|
671
|
+
cancelled_count=int(entry[2]),
|
|
672
|
+
input_tokens=int(entry[3]),
|
|
673
|
+
output_tokens=int(entry[4]),
|
|
674
|
+
cached_input_tokens=int(entry[5]),
|
|
675
|
+
reasoning_tokens=int(entry[6]),
|
|
676
|
+
cost_usd=float(entry[7]),
|
|
677
|
+
)
|
|
678
|
+
for key, entry in sorted(merged.items(), key=lambda item: (item[0][0], item[0][1], item[0][2] or ""))
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
async def aggregate_conversations_by_bucket(
|
|
682
|
+
self,
|
|
683
|
+
since: datetime,
|
|
684
|
+
bucket_seconds: int = 21600,
|
|
685
|
+
) -> list[BucketConversationAggregate]:
|
|
686
|
+
# Hour-multiple display buckets merge the conversation satellite with
|
|
687
|
+
# the raw tail in one UNION statement: COUNT(DISTINCT) over the merge
|
|
688
|
+
# dedups a conversation present on both sides of the fold boundary
|
|
689
|
+
# within one display bucket. Any other granularity degrades to the
|
|
690
|
+
# legacy full-raw scan (a display bucket would split a folded hour).
|
|
691
|
+
if bucket_seconds > 0 and bucket_seconds % HOURLY_BUCKET_SECONDS == 0:
|
|
692
|
+
union = conversation_presence_union(
|
|
693
|
+
self._session,
|
|
694
|
+
since,
|
|
695
|
+
include_deleted=False,
|
|
696
|
+
raw_conditions=self._eligible_conversation_row_conditions(),
|
|
697
|
+
display_bucket_seconds=bucket_seconds,
|
|
698
|
+
).subquery()
|
|
699
|
+
stmt = (
|
|
700
|
+
select(union.c.bucket_epoch, func.count(func.distinct(union.c.cid)).label("conversation_count"))
|
|
701
|
+
.group_by(union.c.bucket_epoch)
|
|
702
|
+
.order_by(union.c.bucket_epoch)
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
bucket_col = self._bucket_epoch_expr(bucket_seconds).label("bucket_epoch")
|
|
706
|
+
conversation_id = self._conversation_id_expr()
|
|
707
|
+
stmt = (
|
|
708
|
+
select(
|
|
709
|
+
bucket_col,
|
|
710
|
+
func.count(func.distinct(conversation_id)).label("conversation_count"),
|
|
711
|
+
)
|
|
712
|
+
.where(
|
|
713
|
+
RequestLog.requested_at >= since,
|
|
714
|
+
*self._eligible_conversation_row_conditions(),
|
|
715
|
+
conversation_id.is_not(None),
|
|
716
|
+
)
|
|
717
|
+
.group_by(bucket_col)
|
|
718
|
+
.order_by(bucket_col)
|
|
719
|
+
)
|
|
720
|
+
result = await self._session.execute(stmt)
|
|
721
|
+
return [
|
|
722
|
+
BucketConversationAggregate(
|
|
723
|
+
bucket_epoch=int(row.bucket_epoch),
|
|
724
|
+
conversation_count=int(row.conversation_count),
|
|
725
|
+
)
|
|
726
|
+
for row in result.all()
|
|
727
|
+
]
|
|
728
|
+
|
|
729
|
+
async def aggregate_activity_since(self, since: datetime) -> RequestActivityAggregate:
|
|
730
|
+
return await self._aggregate_activity(since, None)
|
|
731
|
+
|
|
732
|
+
async def aggregate_activity_between(self, since: datetime, until: datetime) -> RequestActivityAggregate:
|
|
733
|
+
return await self._aggregate_activity(since, until)
|
|
734
|
+
|
|
735
|
+
async def _aggregate_activity(self, since: datetime, until: datetime | None) -> RequestActivityAggregate:
|
|
736
|
+
rollup_rows, raw_windows = await read_hourly_window(
|
|
737
|
+
self._session,
|
|
738
|
+
since,
|
|
739
|
+
until,
|
|
740
|
+
filters=(RequestUsageHourlyRollup.request_kind.not_in(WARMUP_REQUEST_KINDS),),
|
|
741
|
+
)
|
|
742
|
+
request_count = error_count = input_tokens = output_tokens = cached_input_tokens = 0
|
|
743
|
+
cost_usd = 0.0
|
|
744
|
+
for rollup in rollup_rows:
|
|
745
|
+
request_count += rollup.request_count
|
|
746
|
+
error_count += rollup.error_count
|
|
747
|
+
input_tokens += rollup.input_tokens
|
|
748
|
+
output_tokens += rollup.output_tokens
|
|
749
|
+
cached_input_tokens += rollup.cached_input_tokens
|
|
750
|
+
cost_usd += rollup.cost_usd
|
|
751
|
+
if raw_windows:
|
|
752
|
+
totals_stmt = select(
|
|
753
|
+
func.count().label("request_count"),
|
|
754
|
+
func.coalesce(
|
|
755
|
+
func.sum(cast(RequestLog.status.not_in(NON_ERROR_STATUSES), Integer)),
|
|
756
|
+
0,
|
|
757
|
+
).label("error_count"),
|
|
758
|
+
func.coalesce(func.sum(RequestLog.input_tokens), 0).label("input_tokens"),
|
|
759
|
+
func.coalesce(func.sum(RequestLog.output_tokens), 0).label("output_tokens"),
|
|
760
|
+
func.coalesce(func.sum(RequestLog.cached_input_tokens), 0).label("cached_input_tokens"),
|
|
761
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0).label("cost_usd"),
|
|
762
|
+
).where(
|
|
763
|
+
raw_windows_clause(raw_windows),
|
|
764
|
+
self._exclude_warmup_clause(),
|
|
765
|
+
)
|
|
766
|
+
row = (await self._session.execute(totals_stmt)).one()
|
|
767
|
+
request_count += int(row.request_count)
|
|
768
|
+
error_count += int(row.error_count)
|
|
769
|
+
input_tokens += int(row.input_tokens)
|
|
770
|
+
output_tokens += int(row.output_tokens)
|
|
771
|
+
cached_input_tokens += int(row.cached_input_tokens)
|
|
772
|
+
cost_usd += float(row.cost_usd or 0.0)
|
|
773
|
+
|
|
774
|
+
# Distinct conversation counts are not additive across the fold
|
|
775
|
+
# boundary, so they merge the conversation satellite with the raw
|
|
776
|
+
# tail via UNION ALL in one statement: COUNT(DISTINCT) dedups a
|
|
777
|
+
# conversation straddling the boundary, SUM(request_count) stays
|
|
778
|
+
# additive. This keeps the legacy read split in two statements —
|
|
779
|
+
# totals and conversation metrics can straddle a concurrent insert,
|
|
780
|
+
# which the periodically-polled dashboard tolerates.
|
|
781
|
+
union = conversation_presence_union(
|
|
782
|
+
self._session,
|
|
783
|
+
since,
|
|
784
|
+
until,
|
|
785
|
+
include_deleted=False,
|
|
786
|
+
raw_conditions=self._eligible_conversation_row_conditions(),
|
|
787
|
+
).subquery()
|
|
788
|
+
conversation_stmt = select(
|
|
789
|
+
func.count(func.distinct(union.c.cid)).label("conversation_count"),
|
|
790
|
+
func.coalesce(func.sum(union.c.request_count), 0).label("conversation_request_count"),
|
|
791
|
+
)
|
|
792
|
+
conversation_row = (await self._session.execute(conversation_stmt)).one()
|
|
793
|
+
|
|
794
|
+
return RequestActivityAggregate(
|
|
795
|
+
request_count=request_count,
|
|
796
|
+
error_count=error_count,
|
|
797
|
+
input_tokens=input_tokens,
|
|
798
|
+
output_tokens=output_tokens,
|
|
799
|
+
cached_input_tokens=cached_input_tokens,
|
|
800
|
+
cost_usd=cost_usd,
|
|
801
|
+
cancelled_count=await self._cancelled_count(since, until),
|
|
802
|
+
conversation_count=int(conversation_row.conversation_count or 0),
|
|
803
|
+
conversation_request_count=int(conversation_row.conversation_request_count or 0),
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
async def _cancelled_count(self, since: datetime, until: datetime | None) -> int:
|
|
807
|
+
# Sourced from the demand rollup (status is a dimension there, unlike
|
|
808
|
+
# the hourly rollup, and it has carried the full status grain across
|
|
809
|
+
# all folded history), so the cancelled breakdown is accurate even
|
|
810
|
+
# for buckets folded before the hourly `cancelled_count` measure
|
|
811
|
+
# existed and stays consistent with the demand-served listing totals.
|
|
812
|
+
folded_total, raw_windows = await sum_demand_window(
|
|
813
|
+
self._session,
|
|
814
|
+
since,
|
|
815
|
+
until,
|
|
816
|
+
filters=(
|
|
817
|
+
RequestDemandQuarterRollup.status == CANCELLED_STATUS,
|
|
818
|
+
RequestDemandQuarterRollup.request_kind.not_in(WARMUP_REQUEST_KINDS),
|
|
819
|
+
),
|
|
820
|
+
)
|
|
821
|
+
if not raw_windows:
|
|
822
|
+
return folded_total
|
|
823
|
+
tail_stmt = select(func.count()).where(
|
|
824
|
+
raw_windows_clause(raw_windows),
|
|
825
|
+
self._exclude_warmup_clause(),
|
|
826
|
+
RequestLog.status == CANCELLED_STATUS,
|
|
827
|
+
)
|
|
828
|
+
tail_total = (await self._session.execute(tail_stmt)).scalar_one()
|
|
829
|
+
return folded_total + int(tail_total)
|
|
830
|
+
|
|
831
|
+
async def top_error_since(self, since: datetime) -> str | None:
|
|
832
|
+
return await self._top_error(since, None)
|
|
833
|
+
|
|
834
|
+
async def aggregate_usage_metrics_since(self, since: datetime) -> UsageSummaryLogsAggregate:
|
|
835
|
+
"""Aggregate the usage-summary window in SQL instead of hydrating
|
|
836
|
+
every RequestLog row (the secondary window is typically 7 days).
|
|
837
|
+
|
|
838
|
+
Matches the Python log helpers exactly: output tokens fall back to
|
|
839
|
+
reasoning tokens, cached tokens clamp per-row to [0, input_tokens],
|
|
840
|
+
and models whose costs are all NULL are omitted from per-model cost.
|
|
841
|
+
"""
|
|
842
|
+
dialect = self._session.get_bind().dialect.name
|
|
843
|
+
# SQLite's two-argument min()/max() scalar functions are its
|
|
844
|
+
# least()/greatest().
|
|
845
|
+
least = func.least if dialect == "postgresql" else func.min
|
|
846
|
+
greatest = func.greatest if dialect == "postgresql" else func.max
|
|
847
|
+
|
|
848
|
+
window = [RequestLog.requested_at >= since, self._exclude_warmup_clause()]
|
|
849
|
+
output_expr = func.coalesce(RequestLog.output_tokens, RequestLog.reasoning_tokens, 0)
|
|
850
|
+
tokens_expr = func.coalesce(RequestLog.input_tokens, 0) + output_expr
|
|
851
|
+
cached_expr = case(
|
|
852
|
+
(RequestLog.cached_input_tokens.is_(None), 0),
|
|
853
|
+
(RequestLog.input_tokens.is_(None), greatest(0, RequestLog.cached_input_tokens)),
|
|
854
|
+
else_=greatest(0, least(RequestLog.cached_input_tokens, RequestLog.input_tokens)),
|
|
855
|
+
)
|
|
856
|
+
# ONE statement = one snapshot: totals, top error, and per-model cost
|
|
857
|
+
# must describe the same committed row set (the legacy path read one
|
|
858
|
+
# SELECT and reduced it in Python, which was internally consistent;
|
|
859
|
+
# separate statements under READ COMMITTED are not). The grouped
|
|
860
|
+
# result stays tiny (models x error codes) and everything derives
|
|
861
|
+
# from it in Python. Cancelled terminals are classified out of the
|
|
862
|
+
# error fold (they are normal client disconnects, not upstream
|
|
863
|
+
# failures) and counted separately.
|
|
864
|
+
is_error_expr = RequestLog.status.not_in(NON_ERROR_STATUSES).label("is_error")
|
|
865
|
+
is_cancelled_expr = (RequestLog.status == CANCELLED_STATUS).label("is_cancelled")
|
|
866
|
+
rows = (
|
|
867
|
+
await self._session.execute(
|
|
868
|
+
select(
|
|
869
|
+
RequestLog.model,
|
|
870
|
+
is_error_expr,
|
|
871
|
+
is_cancelled_expr,
|
|
872
|
+
RequestLog.error_code,
|
|
873
|
+
func.count().label("request_count"),
|
|
874
|
+
func.coalesce(func.sum(tokens_expr), 0).label("total_tokens"),
|
|
875
|
+
func.coalesce(func.sum(cached_expr), 0).label("cached_input_tokens"),
|
|
876
|
+
func.sum(RequestLog.cost_usd).label("cost_usd"),
|
|
877
|
+
func.count(RequestLog.cost_usd).label("cost_count"),
|
|
878
|
+
)
|
|
879
|
+
.where(*window)
|
|
880
|
+
.group_by(RequestLog.model, is_error_expr, is_cancelled_expr, RequestLog.error_code)
|
|
881
|
+
)
|
|
882
|
+
).all()
|
|
883
|
+
|
|
884
|
+
request_count = 0
|
|
885
|
+
error_count = 0
|
|
886
|
+
cancelled_count = 0
|
|
887
|
+
total_tokens = 0
|
|
888
|
+
cached_input_tokens = 0
|
|
889
|
+
error_code_counts: dict[str, int] = {}
|
|
890
|
+
cost_sums: dict[str, float] = {}
|
|
891
|
+
cost_counts: dict[str, int] = {}
|
|
892
|
+
for (
|
|
893
|
+
model,
|
|
894
|
+
is_error,
|
|
895
|
+
is_cancelled,
|
|
896
|
+
error_code,
|
|
897
|
+
group_count,
|
|
898
|
+
group_tokens,
|
|
899
|
+
group_cached,
|
|
900
|
+
group_cost,
|
|
901
|
+
cost_count,
|
|
902
|
+
) in rows:
|
|
903
|
+
group_count = int(group_count or 0)
|
|
904
|
+
request_count += group_count
|
|
905
|
+
total_tokens += int(group_tokens or 0)
|
|
906
|
+
cached_input_tokens += int(group_cached or 0)
|
|
907
|
+
if is_error:
|
|
908
|
+
error_count += group_count
|
|
909
|
+
if error_code:
|
|
910
|
+
error_code_counts[error_code] = error_code_counts.get(error_code, 0) + group_count
|
|
911
|
+
elif is_cancelled:
|
|
912
|
+
cancelled_count += group_count
|
|
913
|
+
cost_sums[model] = cost_sums.get(model, 0.0) + float(group_cost or 0.0)
|
|
914
|
+
cost_counts[model] = cost_counts.get(model, 0) + int(cost_count or 0)
|
|
915
|
+
|
|
916
|
+
top_error = None
|
|
917
|
+
if error_code_counts:
|
|
918
|
+
# Deterministic tie-break: highest count, then code ascending
|
|
919
|
+
# (the same rule the dashboard top-error read uses).
|
|
920
|
+
top_error = min(error_code_counts, key=lambda code: (-error_code_counts[code], code))
|
|
921
|
+
|
|
922
|
+
return UsageSummaryLogsAggregate(
|
|
923
|
+
request_count=request_count,
|
|
924
|
+
error_count=error_count,
|
|
925
|
+
cancelled_count=cancelled_count,
|
|
926
|
+
total_tokens=total_tokens,
|
|
927
|
+
cached_input_tokens=cached_input_tokens,
|
|
928
|
+
top_error=top_error,
|
|
929
|
+
# Models whose costs are all NULL stay out, matching the legacy
|
|
930
|
+
# per-row skip of None costs.
|
|
931
|
+
cost_by_model=sorted((model, cost_sums[model]) for model, count in cost_counts.items() if count > 0),
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
async def top_error_between(self, since: datetime, until: datetime) -> str | None:
|
|
935
|
+
return await self._top_error(since, until)
|
|
936
|
+
|
|
937
|
+
async def _top_error(self, since: datetime, until: datetime | None) -> str | None:
|
|
938
|
+
# The error satellite was folded with this exact filter set (warmup
|
|
939
|
+
# kinds excluded, soft-deleted rows INCLUDED, error_code NOT NULL);
|
|
940
|
+
# only the account dimension needs summing over here. Buckets folded
|
|
941
|
+
# before cancelled terminals left the error fold still carry their
|
|
942
|
+
# client_disconnected counts — dropping that code read-side keeps
|
|
943
|
+
# top_error on genuinely-failed terminals without a backfill.
|
|
944
|
+
error_rows, raw_windows = await read_errors_window(
|
|
945
|
+
self._session,
|
|
946
|
+
since,
|
|
947
|
+
until,
|
|
948
|
+
filters=(RequestUsageHourlyErrorRollup.error_code != CLIENT_DISCONNECT_ERROR_CODE,),
|
|
949
|
+
)
|
|
950
|
+
counts: dict[str, int] = {}
|
|
951
|
+
for error in error_rows:
|
|
952
|
+
counts[error.error_code] = counts.get(error.error_code, 0) + error.error_count
|
|
953
|
+
if raw_windows:
|
|
954
|
+
stmt = (
|
|
955
|
+
select(RequestLog.error_code, func.count(RequestLog.id).label("error_count"))
|
|
956
|
+
.where(
|
|
957
|
+
raw_windows_clause(raw_windows),
|
|
958
|
+
self._exclude_warmup_clause(),
|
|
959
|
+
RequestLog.status.not_in(NON_ERROR_STATUSES),
|
|
960
|
+
RequestLog.error_code.is_not(None),
|
|
961
|
+
)
|
|
962
|
+
.group_by(RequestLog.error_code)
|
|
963
|
+
)
|
|
964
|
+
for error_code, error_count in (await self._session.execute(stmt)).all():
|
|
965
|
+
counts[error_code] = counts.get(error_code, 0) + int(error_count)
|
|
966
|
+
if not counts:
|
|
967
|
+
return None
|
|
968
|
+
# Deterministic tie-break: highest count, then code ascending — the
|
|
969
|
+
# rule the legacy single-statement ORDER BY used. The legacy reader
|
|
970
|
+
# also coerced a falsy winner (an empty-string error_code, which the
|
|
971
|
+
# nullable column permits) to None; `or None` reproduces that.
|
|
972
|
+
return min(counts, key=lambda code: (-counts[code], code)) or None
|
|
973
|
+
|
|
974
|
+
async def earliest_activity_at(self) -> datetime | None:
|
|
975
|
+
stmt = select(func.min(RequestLog.requested_at)).where(self._exclude_warmup_clause())
|
|
976
|
+
result = await self._session.execute(stmt)
|
|
977
|
+
value = result.scalar_one_or_none()
|
|
978
|
+
raw_earliest = value if isinstance(value, datetime) else None
|
|
979
|
+
# Raw wins while it survives (sub-hour precision); the whole-hour
|
|
980
|
+
# rollup fallback (keeps history-dependent UI like canCompare
|
|
981
|
+
# working) applies only when the earliest folded bucket lies STRICTLY
|
|
982
|
+
# below raw's own bucket — i.e. retention has pruned earlier raw
|
|
983
|
+
# rows. A folded bucket that merely floors the still-present earliest
|
|
984
|
+
# raw row must not round the result down to the hour.
|
|
985
|
+
rollup_earliest = await earliest_hourly_bucket_at(self._session)
|
|
986
|
+
if rollup_earliest is not None and (raw_earliest is None or rollup_earliest < floor_to_hour(raw_earliest)):
|
|
987
|
+
return rollup_earliest
|
|
988
|
+
return raw_earliest
|
|
989
|
+
|
|
990
|
+
async def add_log(
|
|
991
|
+
self,
|
|
992
|
+
account_id: str | None,
|
|
993
|
+
request_id: str,
|
|
994
|
+
model: str,
|
|
995
|
+
input_tokens: int | None,
|
|
996
|
+
output_tokens: int | None,
|
|
997
|
+
latency_ms: int | None,
|
|
998
|
+
status: str,
|
|
999
|
+
error_code: str | None,
|
|
1000
|
+
latency_first_token_ms: int | None = None,
|
|
1001
|
+
latency_queue_ms: int | None = None,
|
|
1002
|
+
latency_response_created_ms: int | None = None,
|
|
1003
|
+
latency_first_upstream_event_ms: int | None = None,
|
|
1004
|
+
latency_response_create_gate_wait_ms: int | None = None,
|
|
1005
|
+
latency_bridge_queue_wait_ms: int | None = None,
|
|
1006
|
+
prewarm_status: str | None = None,
|
|
1007
|
+
prewarm_latency_ms: int | None = None,
|
|
1008
|
+
session_previous_gap_ms: int | None = None,
|
|
1009
|
+
error_message: str | None = None,
|
|
1010
|
+
requested_at: datetime | None = None,
|
|
1011
|
+
cached_input_tokens: int | None = None,
|
|
1012
|
+
reasoning_tokens: int | None = None,
|
|
1013
|
+
reasoning_effort: str | None = None,
|
|
1014
|
+
service_tier: str | None = None,
|
|
1015
|
+
requested_service_tier: str | None = None,
|
|
1016
|
+
actual_service_tier: str | None = None,
|
|
1017
|
+
transport: str | None = None,
|
|
1018
|
+
upstream_transport: str | None = None,
|
|
1019
|
+
api_key_id: str | None = None,
|
|
1020
|
+
session_id: str | None = None,
|
|
1021
|
+
plan_type: str | None = None,
|
|
1022
|
+
source: str | None = None,
|
|
1023
|
+
useragent: str | None = None,
|
|
1024
|
+
useragent_group: str | None = None,
|
|
1025
|
+
conversation_id: str | None = None,
|
|
1026
|
+
client_ip: str | None = None,
|
|
1027
|
+
failure_phase: str | None = None,
|
|
1028
|
+
failure_detail: str | None = None,
|
|
1029
|
+
failure_exception_type: str | None = None,
|
|
1030
|
+
upstream_status_code: int | None = None,
|
|
1031
|
+
upstream_error_code: str | None = None,
|
|
1032
|
+
model_source_id: str | None = None,
|
|
1033
|
+
model_source_kind: str | None = None,
|
|
1034
|
+
cost_usd: float | None = None,
|
|
1035
|
+
bridge_stage: str | None = None,
|
|
1036
|
+
request_kind: str = RequestKind.NORMAL.value,
|
|
1037
|
+
connection_request_kind: str | None = None,
|
|
1038
|
+
upstream_proxy_route_mode: str | None = None,
|
|
1039
|
+
upstream_proxy_pool_id: str | None = None,
|
|
1040
|
+
upstream_proxy_endpoint_id: str | None = None,
|
|
1041
|
+
upstream_proxy_fallback_used: bool | None = None,
|
|
1042
|
+
upstream_proxy_fail_closed_reason: str | None = None,
|
|
1043
|
+
archive_request_id: str | None = None,
|
|
1044
|
+
sticky_key_source: str | None = None,
|
|
1045
|
+
sticky_kind: str | None = None,
|
|
1046
|
+
sticky_key_hash: str | None = None,
|
|
1047
|
+
) -> RequestLog:
|
|
1048
|
+
async with sqlite_writer_section():
|
|
1049
|
+
# Telemetry write: this transaction only appends one request-log
|
|
1050
|
+
# row, so its commit may skip the synchronous WAL flush.
|
|
1051
|
+
await relax_commit_durability(self._session)
|
|
1052
|
+
resolved_request_id = ensure_request_id(request_id)
|
|
1053
|
+
resolved_archive_request_id = (archive_request_id or "").strip() or resolved_request_id
|
|
1054
|
+
resolved_plan_type = plan_type
|
|
1055
|
+
if resolved_plan_type is None and account_id:
|
|
1056
|
+
resolved_plan_type = await self._resolve_account_plan_type(account_id)
|
|
1057
|
+
resolved_useragent = useragent if not isinstance(useragent, str) or useragent.strip() else None
|
|
1058
|
+
resolved_useragent_group = (
|
|
1059
|
+
useragent_group if not isinstance(useragent_group, str) or useragent_group.strip() else None
|
|
1060
|
+
)
|
|
1061
|
+
resolved_conversation_id = _normalize_conversation_id(conversation_id)
|
|
1062
|
+
resolved_client_ip = client_ip if not isinstance(client_ip, str) or client_ip.strip() else None
|
|
1063
|
+
log = RequestLog(
|
|
1064
|
+
sticky_key_source=sticky_key_source,
|
|
1065
|
+
sticky_kind=sticky_kind,
|
|
1066
|
+
sticky_key_hash=sticky_key_hash,
|
|
1067
|
+
account_id=account_id,
|
|
1068
|
+
model_source_id=model_source_id,
|
|
1069
|
+
model_source_kind=model_source_kind,
|
|
1070
|
+
api_key_id=api_key_id,
|
|
1071
|
+
session_id=session_id,
|
|
1072
|
+
request_id=resolved_request_id,
|
|
1073
|
+
archive_request_id=resolved_archive_request_id,
|
|
1074
|
+
model=model,
|
|
1075
|
+
plan_type=resolved_plan_type,
|
|
1076
|
+
source=source,
|
|
1077
|
+
transport=transport,
|
|
1078
|
+
upstream_transport=upstream_transport,
|
|
1079
|
+
request_kind=request_kind,
|
|
1080
|
+
connection_request_kind=connection_request_kind,
|
|
1081
|
+
useragent=resolved_useragent,
|
|
1082
|
+
useragent_group=resolved_useragent_group,
|
|
1083
|
+
conversation_id=resolved_conversation_id,
|
|
1084
|
+
client_ip=resolved_client_ip,
|
|
1085
|
+
service_tier=service_tier,
|
|
1086
|
+
requested_service_tier=requested_service_tier,
|
|
1087
|
+
actual_service_tier=actual_service_tier,
|
|
1088
|
+
input_tokens=input_tokens,
|
|
1089
|
+
output_tokens=output_tokens,
|
|
1090
|
+
cached_input_tokens=cached_input_tokens,
|
|
1091
|
+
reasoning_tokens=reasoning_tokens,
|
|
1092
|
+
cost_usd=None,
|
|
1093
|
+
reasoning_effort=reasoning_effort,
|
|
1094
|
+
latency_ms=latency_ms,
|
|
1095
|
+
latency_first_token_ms=latency_first_token_ms,
|
|
1096
|
+
latency_queue_ms=latency_queue_ms,
|
|
1097
|
+
latency_response_created_ms=latency_response_created_ms,
|
|
1098
|
+
latency_first_upstream_event_ms=latency_first_upstream_event_ms,
|
|
1099
|
+
latency_response_create_gate_wait_ms=latency_response_create_gate_wait_ms,
|
|
1100
|
+
latency_bridge_queue_wait_ms=latency_bridge_queue_wait_ms,
|
|
1101
|
+
prewarm_status=prewarm_status,
|
|
1102
|
+
prewarm_latency_ms=prewarm_latency_ms,
|
|
1103
|
+
session_previous_gap_ms=session_previous_gap_ms,
|
|
1104
|
+
status=status,
|
|
1105
|
+
error_code=error_code,
|
|
1106
|
+
error_message=error_message,
|
|
1107
|
+
failure_phase=failure_phase,
|
|
1108
|
+
failure_detail=failure_detail,
|
|
1109
|
+
failure_exception_type=failure_exception_type,
|
|
1110
|
+
upstream_status_code=upstream_status_code,
|
|
1111
|
+
upstream_error_code=upstream_error_code,
|
|
1112
|
+
bridge_stage=bridge_stage,
|
|
1113
|
+
upstream_proxy_route_mode=upstream_proxy_route_mode,
|
|
1114
|
+
upstream_proxy_pool_id=upstream_proxy_pool_id,
|
|
1115
|
+
upstream_proxy_endpoint_id=upstream_proxy_endpoint_id,
|
|
1116
|
+
upstream_proxy_fallback_used=upstream_proxy_fallback_used,
|
|
1117
|
+
upstream_proxy_fail_closed_reason=upstream_proxy_fail_closed_reason,
|
|
1118
|
+
requested_at=requested_at or utcnow(),
|
|
1119
|
+
)
|
|
1120
|
+
log.cost_usd = (
|
|
1121
|
+
cost_usd
|
|
1122
|
+
if cost_usd is not None
|
|
1123
|
+
else 0.0
|
|
1124
|
+
if model_source_id is not None
|
|
1125
|
+
else calculated_cost_from_log(typing_cast(RequestLogLike, log))
|
|
1126
|
+
)
|
|
1127
|
+
# Core insert instead of unit-of-work: the row is fully built
|
|
1128
|
+
# above, so the ORM flush (relationship cascade scan,
|
|
1129
|
+
# per-attribute history snapshots) is pure overhead on every
|
|
1130
|
+
# request's log write. No refresh: every column is set explicitly
|
|
1131
|
+
# before insert. Once the primary key is known, the instance is
|
|
1132
|
+
# re-attached as *persistent* (clean, no pending SQL) so callers
|
|
1133
|
+
# that mutate the returned log and commit through the same
|
|
1134
|
+
# session get a tracked UPDATE instead of a silent no-op
|
|
1135
|
+
# (sessions run with ``expire_on_commit=False``, so the attach
|
|
1136
|
+
# never triggers post-commit lazy loads).
|
|
1137
|
+
insert_values = {key: getattr(log, key) for key in _REQUEST_LOG_INSERT_COLUMN_KEYS}
|
|
1138
|
+
try:
|
|
1139
|
+
result = typing_cast(
|
|
1140
|
+
CursorResult[Any],
|
|
1141
|
+
await self._session.execute(insert(RequestLog).values(insert_values)),
|
|
1142
|
+
)
|
|
1143
|
+
inserted_primary_key = result.inserted_primary_key
|
|
1144
|
+
if inserted_primary_key is not None and inserted_primary_key[0] is not None:
|
|
1145
|
+
log.id = int(inserted_primary_key[0])
|
|
1146
|
+
make_transient_to_detached(log)
|
|
1147
|
+
self._session.add(log)
|
|
1148
|
+
await self._session.commit()
|
|
1149
|
+
return log
|
|
1150
|
+
except sa_exc.ResourceClosedError:
|
|
1151
|
+
return log
|
|
1152
|
+
except BaseException:
|
|
1153
|
+
await _safe_rollback(self._session)
|
|
1154
|
+
raise
|
|
1155
|
+
|
|
1156
|
+
async def latest_successful_log_for_request_id(self, request_id: str, *, request_kind: str) -> RequestLog | None:
|
|
1157
|
+
"""Return the newest *successful* ``request_kind`` log for ``request_id``.
|
|
1158
|
+
|
|
1159
|
+
A stable request id can carry more than one row. A warmup probe that
|
|
1160
|
+
succeeded upstream and then failed while recording its effect writes a
|
|
1161
|
+
success row followed by an error row under the same id, so a caller
|
|
1162
|
+
reconciling "did this request already happen upstream" must not read
|
|
1163
|
+
only the newest row: the status and kind belong in the query.
|
|
1164
|
+
"""
|
|
1165
|
+
|
|
1166
|
+
stmt = (
|
|
1167
|
+
select(RequestLog)
|
|
1168
|
+
.where(
|
|
1169
|
+
RequestLog.request_id == ensure_request_id(request_id),
|
|
1170
|
+
RequestLog.request_kind == request_kind,
|
|
1171
|
+
RequestLog.status == "success",
|
|
1172
|
+
)
|
|
1173
|
+
.order_by(RequestLog.requested_at.desc(), RequestLog.id.desc())
|
|
1174
|
+
.limit(1)
|
|
1175
|
+
)
|
|
1176
|
+
return await self._session.scalar(stmt)
|
|
1177
|
+
|
|
1178
|
+
async def update_model_for_request(self, request_id: str, model: str) -> int:
|
|
1179
|
+
"""Override the ``model`` field of any logs matching ``request_id``.
|
|
1180
|
+
|
|
1181
|
+
Used by route handlers that translate a public request shape (e.g.
|
|
1182
|
+
``/v1/images/generations``) into an internal Responses request: the
|
|
1183
|
+
first-pass log row stores the internal host model used for routing,
|
|
1184
|
+
and we rewrite it here once the public effective model is known so
|
|
1185
|
+
the dashboard and usage views surface the user-visible model.
|
|
1186
|
+
|
|
1187
|
+
Only rows in the un-folded live tail — strictly above the lifetime
|
|
1188
|
+
watermark (its fold interval is ``(start, end]``) AND at or above the
|
|
1189
|
+
hourly and report watermarks (half-open ``[start, end)``) — are rewritten:
|
|
1190
|
+
``model`` is a rollup dimension and ``cost_usd`` a folded measure, so
|
|
1191
|
+
mutating a row any rollup already captured would silently diverge
|
|
1192
|
+
that rollup from raw (and the divergence becomes unrepairable once
|
|
1193
|
+
retention prunes the raw row).
|
|
1194
|
+
A matching row below the watermarks can only be a client-reused
|
|
1195
|
+
request id colliding with unrelated old traffic — the rewrite's
|
|
1196
|
+
target is the row the caller inserted moments ago. The fold-state
|
|
1197
|
+
lock serializes this against an in-flight fold slice. The
|
|
1198
|
+
conversation satellite needs no bound here: neither ``model`` nor
|
|
1199
|
+
``cost_usd`` is folded into it.
|
|
1200
|
+
|
|
1201
|
+
Returns the number of rows that were updated.
|
|
1202
|
+
"""
|
|
1203
|
+
async with sqlite_writer_section():
|
|
1204
|
+
resolved_request_id = ensure_request_id(request_id)
|
|
1205
|
+
try:
|
|
1206
|
+
await lock_fold_state(self._session)
|
|
1207
|
+
watermarks = (
|
|
1208
|
+
await self._session.execute(
|
|
1209
|
+
select(
|
|
1210
|
+
AccountUsageRollupState.folded_through,
|
|
1211
|
+
AccountUsageRollupState.hourly_folded_through,
|
|
1212
|
+
AccountUsageRollupState.reports_folded_through,
|
|
1213
|
+
).where(AccountUsageRollupState.id == 1)
|
|
1214
|
+
)
|
|
1215
|
+
).first()
|
|
1216
|
+
# Fetch the affected rows so we can recompute ``cost_usd``
|
|
1217
|
+
# from the new model. ``add_log`` derives the cost at insert
|
|
1218
|
+
# time from the original (host) model; without recomputing
|
|
1219
|
+
# here, dashboards would mix the public ``gpt-image-*`` model
|
|
1220
|
+
# label with host-model pricing and report inaccurate cost.
|
|
1221
|
+
stmt = select(RequestLog).where(RequestLog.request_id == resolved_request_id)
|
|
1222
|
+
if watermarks is not None:
|
|
1223
|
+
# A row is un-folded by EVERY rollup only when it clears
|
|
1224
|
+
# all bounds, each matching its fold's own interval
|
|
1225
|
+
# convention: the lifetime fold is `(start, end]`
|
|
1226
|
+
# (inclusive end — a row AT the watermark is folded), the
|
|
1227
|
+
# hourly and report folds are `[start, end)`.
|
|
1228
|
+
folded_through, hourly_folded_through, reports_folded_through = watermarks
|
|
1229
|
+
stmt = stmt.where(
|
|
1230
|
+
RequestLog.requested_at > folded_through,
|
|
1231
|
+
RequestLog.requested_at >= hourly_folded_through,
|
|
1232
|
+
RequestLog.requested_at >= reports_folded_through,
|
|
1233
|
+
)
|
|
1234
|
+
result_rows = await self._session.execute(stmt)
|
|
1235
|
+
logs = list(result_rows.scalars())
|
|
1236
|
+
if not logs:
|
|
1237
|
+
# End the transaction: the fold-state row lock (and the
|
|
1238
|
+
# bootstrap insert behind it) must not outlive this call.
|
|
1239
|
+
await _safe_rollback(self._session)
|
|
1240
|
+
return 0
|
|
1241
|
+
for log in logs:
|
|
1242
|
+
log.model = model
|
|
1243
|
+
log.cost_usd = calculated_cost_from_log(typing_cast(RequestLogLike, log))
|
|
1244
|
+
await self._session.commit()
|
|
1245
|
+
except sa_exc.ResourceClosedError:
|
|
1246
|
+
return 0
|
|
1247
|
+
except BaseException:
|
|
1248
|
+
await _safe_rollback(self._session)
|
|
1249
|
+
raise
|
|
1250
|
+
return len(logs)
|
|
1251
|
+
|
|
1252
|
+
async def list_recent(
|
|
1253
|
+
self,
|
|
1254
|
+
limit: int = 50,
|
|
1255
|
+
offset: int = 0,
|
|
1256
|
+
search: str | None = None,
|
|
1257
|
+
since: datetime | None = None,
|
|
1258
|
+
until: datetime | None = None,
|
|
1259
|
+
conversation_id: str | None = None,
|
|
1260
|
+
account_ids: list[str] | None = None,
|
|
1261
|
+
api_key_ids: list[str] | None = None,
|
|
1262
|
+
model_options: list[tuple[str, str | None]] | None = None,
|
|
1263
|
+
models: list[str] | None = None,
|
|
1264
|
+
reasoning_efforts: list[str] | None = None,
|
|
1265
|
+
include_success: bool = True,
|
|
1266
|
+
include_cancelled: bool = True,
|
|
1267
|
+
include_error_other: bool = True,
|
|
1268
|
+
error_codes_in: list[str] | None = None,
|
|
1269
|
+
error_codes_excluding: list[str] | None = None,
|
|
1270
|
+
*,
|
|
1271
|
+
cache_mode: str = "since",
|
|
1272
|
+
timeframe: str | None = None,
|
|
1273
|
+
include_sensitive_metadata: bool = True,
|
|
1274
|
+
include_account_identity: bool = True,
|
|
1275
|
+
include_api_key_identity: bool = True,
|
|
1276
|
+
) -> RequestLogsResult:
|
|
1277
|
+
since = _naive_utc(since) if since is not None else None
|
|
1278
|
+
until = _naive_utc(until) if until is not None else None
|
|
1279
|
+
filters = self._build_filters(
|
|
1280
|
+
search=search,
|
|
1281
|
+
since=since,
|
|
1282
|
+
until=until,
|
|
1283
|
+
conversation_id=conversation_id,
|
|
1284
|
+
account_ids=account_ids,
|
|
1285
|
+
api_key_ids=api_key_ids,
|
|
1286
|
+
model_options=model_options,
|
|
1287
|
+
models=models,
|
|
1288
|
+
reasoning_efforts=reasoning_efforts,
|
|
1289
|
+
include_success=include_success,
|
|
1290
|
+
include_cancelled=include_cancelled,
|
|
1291
|
+
include_error_other=include_error_other,
|
|
1292
|
+
error_codes_in=error_codes_in,
|
|
1293
|
+
error_codes_excluding=error_codes_excluding,
|
|
1294
|
+
exclude_soft_deleted=True,
|
|
1295
|
+
include_sensitive_metadata=include_sensitive_metadata,
|
|
1296
|
+
include_account_identity=include_account_identity,
|
|
1297
|
+
include_api_key_identity=include_api_key_identity,
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
stmt = select(RequestLog).order_by(RequestLog.requested_at.desc(), RequestLog.id.desc())
|
|
1301
|
+
stmt = self._apply_related_search_joins(stmt, filters.needs_related_search_joins)
|
|
1302
|
+
if filters.conditions:
|
|
1303
|
+
stmt = stmt.where(and_(*filters.conditions))
|
|
1304
|
+
if offset:
|
|
1305
|
+
stmt = stmt.offset(offset)
|
|
1306
|
+
if limit:
|
|
1307
|
+
stmt = stmt.limit(limit)
|
|
1308
|
+
result = await self._session.execute(stmt)
|
|
1309
|
+
logs = list(result.scalars().all())
|
|
1310
|
+
|
|
1311
|
+
if conversation_id is not None:
|
|
1312
|
+
total, aggregated_cost_usd = await self._count_and_sum_recent(filters)
|
|
1313
|
+
return RequestLogsResult(logs=logs, total=total, aggregated_cost_usd=aggregated_cost_usd)
|
|
1314
|
+
|
|
1315
|
+
demand_params: _DemandCountParams | None = None
|
|
1316
|
+
if search is None and not error_codes_in and not error_codes_excluding:
|
|
1317
|
+
demand_params = _DemandCountParams(
|
|
1318
|
+
since=since,
|
|
1319
|
+
until=until,
|
|
1320
|
+
account_ids=account_ids,
|
|
1321
|
+
api_key_ids=api_key_ids,
|
|
1322
|
+
model_options=model_options,
|
|
1323
|
+
models=models,
|
|
1324
|
+
reasoning_efforts=reasoning_efforts,
|
|
1325
|
+
include_success=include_success,
|
|
1326
|
+
include_cancelled=include_cancelled,
|
|
1327
|
+
include_error_other=include_error_other,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
ttl_seconds = _COUNT_CACHE_TTL_SECONDS
|
|
1331
|
+
if ttl_seconds <= 0:
|
|
1332
|
+
return RequestLogsResult(logs=logs, total=await self._count_recent(filters, demand_params))
|
|
1333
|
+
window_identity = ("timeframe", timeframe) if cache_mode == "timeframe" else ("since", since)
|
|
1334
|
+
cache_key = (
|
|
1335
|
+
search,
|
|
1336
|
+
window_identity,
|
|
1337
|
+
until,
|
|
1338
|
+
conversation_id,
|
|
1339
|
+
tuple(account_ids or ()),
|
|
1340
|
+
tuple(api_key_ids or ()),
|
|
1341
|
+
tuple(model_options or ()),
|
|
1342
|
+
tuple(models or ()),
|
|
1343
|
+
tuple(reasoning_efforts or ()),
|
|
1344
|
+
include_success,
|
|
1345
|
+
include_cancelled,
|
|
1346
|
+
include_error_other,
|
|
1347
|
+
tuple(sorted(error_codes_in)) if error_codes_in else None,
|
|
1348
|
+
tuple(sorted(error_codes_excluding)) if error_codes_excluding else None,
|
|
1349
|
+
include_sensitive_metadata,
|
|
1350
|
+
include_account_identity,
|
|
1351
|
+
include_api_key_identity,
|
|
1352
|
+
)
|
|
1353
|
+
total = _cached_recent_count(cache_key)
|
|
1354
|
+
if total is None:
|
|
1355
|
+
total = await self._count_recent(filters, demand_params)
|
|
1356
|
+
_store_recent_count(cache_key, total, ttl_seconds)
|
|
1357
|
+
return RequestLogsResult(logs=logs, total=total)
|
|
1358
|
+
|
|
1359
|
+
async def _count_and_sum_recent(self, filters: _RequestLogFilters) -> tuple[int, float]:
|
|
1360
|
+
aggregate_stmt = select(
|
|
1361
|
+
func.count(),
|
|
1362
|
+
func.coalesce(func.sum(RequestLog.cost_usd), 0.0),
|
|
1363
|
+
).select_from(RequestLog)
|
|
1364
|
+
aggregate_stmt = self._apply_related_search_joins(aggregate_stmt, filters.needs_related_search_joins)
|
|
1365
|
+
if filters.conditions:
|
|
1366
|
+
aggregate_stmt = aggregate_stmt.where(and_(*filters.conditions))
|
|
1367
|
+
result = await self._session.execute(aggregate_stmt)
|
|
1368
|
+
request_count, aggregated_cost_usd = result.one()
|
|
1369
|
+
return int(request_count), float(aggregated_cost_usd)
|
|
1370
|
+
|
|
1371
|
+
async def _count_recent(
|
|
1372
|
+
self,
|
|
1373
|
+
filters: _RequestLogFilters,
|
|
1374
|
+
demand_params: _DemandCountParams | None = None,
|
|
1375
|
+
) -> int:
|
|
1376
|
+
if demand_params is not None:
|
|
1377
|
+
return await self._count_recent_from_demand_rollup(filters, demand_params)
|
|
1378
|
+
count_stmt = select(func.count(RequestLog.id)).select_from(RequestLog)
|
|
1379
|
+
count_stmt = self._apply_related_search_joins(count_stmt, filters.needs_related_search_joins)
|
|
1380
|
+
if filters.conditions:
|
|
1381
|
+
count_stmt = count_stmt.where(and_(*filters.conditions))
|
|
1382
|
+
result = await self._session.execute(count_stmt)
|
|
1383
|
+
return int(result.scalar_one())
|
|
1384
|
+
|
|
1385
|
+
async def _count_recent_from_demand_rollup(
|
|
1386
|
+
self,
|
|
1387
|
+
filters: _RequestLogFilters,
|
|
1388
|
+
params: _DemandCountParams,
|
|
1389
|
+
) -> int:
|
|
1390
|
+
"""Serve the listing total from the demand rollup plus the raw tail.
|
|
1391
|
+
|
|
1392
|
+
Every filter the listing exposes except free-text search and
|
|
1393
|
+
error-code splits maps onto a demand-rollup dimension (status is a
|
|
1394
|
+
dimension there, unlike the hourly rollup), so the folded part of
|
|
1395
|
+
the window is one indexed SUM instead of a scan that grows with
|
|
1396
|
+
history. The un-folded complement is counted from raw with the
|
|
1397
|
+
exact listing conditions. With no watermark the folded sum is 0 and
|
|
1398
|
+
the raw windows cover the whole range, which is the legacy count —
|
|
1399
|
+
no kill switch needed.
|
|
1400
|
+
"""
|
|
1401
|
+
rollup_filters: list = [RequestDemandQuarterRollup.is_deleted.is_(False)]
|
|
1402
|
+
if params.account_ids:
|
|
1403
|
+
rollup_filters.append(
|
|
1404
|
+
RequestDemandQuarterRollup.account_id.in_([to_dimension(value) for value in params.account_ids])
|
|
1405
|
+
)
|
|
1406
|
+
if params.api_key_ids:
|
|
1407
|
+
rollup_filters.append(
|
|
1408
|
+
RequestDemandQuarterRollup.api_key_id.in_([to_dimension(value) for value in params.api_key_ids])
|
|
1409
|
+
)
|
|
1410
|
+
if params.model_options:
|
|
1411
|
+
pair_filters = []
|
|
1412
|
+
for model, effort in params.model_options:
|
|
1413
|
+
base = (model or "").strip()
|
|
1414
|
+
if not base:
|
|
1415
|
+
continue
|
|
1416
|
+
pair_filters.append(
|
|
1417
|
+
and_(
|
|
1418
|
+
RequestDemandQuarterRollup.model == base,
|
|
1419
|
+
RequestDemandQuarterRollup.reasoning_effort == to_dimension(effort),
|
|
1420
|
+
)
|
|
1421
|
+
)
|
|
1422
|
+
if pair_filters:
|
|
1423
|
+
rollup_filters.append(or_(*pair_filters))
|
|
1424
|
+
else:
|
|
1425
|
+
if params.models:
|
|
1426
|
+
rollup_filters.append(RequestDemandQuarterRollup.model.in_(params.models))
|
|
1427
|
+
if params.reasoning_efforts:
|
|
1428
|
+
rollup_filters.append(
|
|
1429
|
+
RequestDemandQuarterRollup.reasoning_effort.in_(
|
|
1430
|
+
[to_dimension(value) for value in params.reasoning_efforts]
|
|
1431
|
+
)
|
|
1432
|
+
)
|
|
1433
|
+
statuses = []
|
|
1434
|
+
if params.include_success:
|
|
1435
|
+
statuses.append("success")
|
|
1436
|
+
if params.include_cancelled:
|
|
1437
|
+
statuses.append(CANCELLED_STATUS)
|
|
1438
|
+
if params.include_error_other:
|
|
1439
|
+
statuses.append("error")
|
|
1440
|
+
if statuses:
|
|
1441
|
+
rollup_filters.append(RequestDemandQuarterRollup.status.in_(statuses))
|
|
1442
|
+
|
|
1443
|
+
# Retention prunes raw rows but keeps their folded counts; the
|
|
1444
|
+
# listing total must track listable rows, so clamp the rollup window
|
|
1445
|
+
# to the earliest surviving live row (the slot containing it stays
|
|
1446
|
+
# raw-served, excluding any partially pruned slot). A prune
|
|
1447
|
+
# committing between this read and the sum can transiently overcount
|
|
1448
|
+
# — the same cross-statement exposure every rollup reader accepts.
|
|
1449
|
+
earliest_live = (
|
|
1450
|
+
await self._session.execute(
|
|
1451
|
+
select(func.min(RequestLog.requested_at)).where(RequestLog.deleted_at.is_(None))
|
|
1452
|
+
)
|
|
1453
|
+
).scalar_one_or_none()
|
|
1454
|
+
rollup_since = params.since if params.since is not None else _ROLLUP_EPOCH
|
|
1455
|
+
if earliest_live is None:
|
|
1456
|
+
rollup_since = utcnow().replace(tzinfo=None)
|
|
1457
|
+
else:
|
|
1458
|
+
rollup_since = max(rollup_since, earliest_live)
|
|
1459
|
+
# The listing's `until` bound is inclusive; the rollup window is
|
|
1460
|
+
# half-open, so shift by the smallest representable step
|
|
1461
|
+
# (datetime.max cannot be shifted — treat it as unbounded).
|
|
1462
|
+
rollup_until = (
|
|
1463
|
+
None if params.until is None or params.until == datetime.max else params.until + timedelta(microseconds=1)
|
|
1464
|
+
)
|
|
1465
|
+
folded_total, raw_windows = await sum_demand_window(
|
|
1466
|
+
self._session,
|
|
1467
|
+
rollup_since,
|
|
1468
|
+
rollup_until,
|
|
1469
|
+
filters=rollup_filters,
|
|
1470
|
+
)
|
|
1471
|
+
if not raw_windows:
|
|
1472
|
+
return folded_total
|
|
1473
|
+
tail_stmt = select(func.count(RequestLog.id)).select_from(RequestLog).where(raw_windows_clause(raw_windows))
|
|
1474
|
+
if filters.conditions:
|
|
1475
|
+
tail_stmt = tail_stmt.where(and_(*filters.conditions))
|
|
1476
|
+
tail_total = (await self._session.execute(tail_stmt)).scalar_one()
|
|
1477
|
+
return folded_total + int(tail_total)
|
|
1478
|
+
|
|
1479
|
+
async def _resolve_account_plan_type(self, account_id: str) -> str | None:
|
|
1480
|
+
result = await self._session.execute(select(Account.plan_type).where(Account.id == account_id).limit(1))
|
|
1481
|
+
return result.scalar_one_or_none()
|
|
1482
|
+
|
|
1483
|
+
async def list_filter_options(
|
|
1484
|
+
self,
|
|
1485
|
+
since: datetime | None = None,
|
|
1486
|
+
until: datetime | None = None,
|
|
1487
|
+
account_ids: list[str] | None = None,
|
|
1488
|
+
api_key_ids: list[str] | None = None,
|
|
1489
|
+
model_options: list[tuple[str, str | None]] | None = None,
|
|
1490
|
+
models: list[str] | None = None,
|
|
1491
|
+
reasoning_efforts: list[str] | None = None,
|
|
1492
|
+
) -> tuple[list[str], list[tuple[str, str | None]], list[str], list[tuple[str, str | None]]]:
|
|
1493
|
+
filters = self._build_filters(
|
|
1494
|
+
since=since,
|
|
1495
|
+
until=until,
|
|
1496
|
+
account_ids=account_ids,
|
|
1497
|
+
api_key_ids=api_key_ids,
|
|
1498
|
+
model_options=model_options,
|
|
1499
|
+
models=models,
|
|
1500
|
+
reasoning_efforts=reasoning_efforts,
|
|
1501
|
+
include_success=True,
|
|
1502
|
+
include_cancelled=True,
|
|
1503
|
+
include_error_other=True,
|
|
1504
|
+
error_codes_in=None,
|
|
1505
|
+
error_codes_excluding=None,
|
|
1506
|
+
exclude_soft_deleted=True,
|
|
1507
|
+
)
|
|
1508
|
+
api_key_facet_filters = self._build_filters(
|
|
1509
|
+
since=since,
|
|
1510
|
+
until=until,
|
|
1511
|
+
account_ids=account_ids,
|
|
1512
|
+
api_key_ids=None,
|
|
1513
|
+
model_options=model_options,
|
|
1514
|
+
models=models,
|
|
1515
|
+
reasoning_efforts=reasoning_efforts,
|
|
1516
|
+
include_success=True,
|
|
1517
|
+
include_cancelled=True,
|
|
1518
|
+
include_error_other=True,
|
|
1519
|
+
error_codes_in=None,
|
|
1520
|
+
error_codes_excluding=None,
|
|
1521
|
+
exclude_soft_deleted=True,
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
unfiltered = not any((since, until, account_ids, api_key_ids, model_options, models, reasoning_efforts))
|
|
1525
|
+
if unfiltered:
|
|
1526
|
+
# PostgreSQL has no loose index scan: with no user filters each
|
|
1527
|
+
# DISTINCT below is a full pass over request_logs, four times per
|
|
1528
|
+
# filter-panel load. Emulate the skip scan instead — one indexed
|
|
1529
|
+
# probe per distinct value.
|
|
1530
|
+
return (
|
|
1531
|
+
[value for value in await self._distinct_skip_scan(RequestLog.account_id, filters.conditions) if value],
|
|
1532
|
+
await self._pair_facet_skip_scan(RequestLog.model, RequestLog.reasoning_effort, filters.conditions),
|
|
1533
|
+
[
|
|
1534
|
+
value
|
|
1535
|
+
for value in await self._distinct_skip_scan(RequestLog.api_key_id, api_key_facet_filters.conditions)
|
|
1536
|
+
if value
|
|
1537
|
+
],
|
|
1538
|
+
await self._pair_facet_skip_scan(RequestLog.status, RequestLog.error_code, filters.conditions),
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
account_stmt = select(RequestLog.account_id).distinct().order_by(RequestLog.account_id.asc())
|
|
1542
|
+
model_stmt = (
|
|
1543
|
+
select(RequestLog.model, RequestLog.reasoning_effort)
|
|
1544
|
+
.distinct()
|
|
1545
|
+
.order_by(RequestLog.model.asc(), RequestLog.reasoning_effort.asc())
|
|
1546
|
+
)
|
|
1547
|
+
api_key_stmt = select(RequestLog.api_key_id).distinct().order_by(RequestLog.api_key_id.asc())
|
|
1548
|
+
status_stmt = (
|
|
1549
|
+
select(RequestLog.status, RequestLog.error_code)
|
|
1550
|
+
.distinct()
|
|
1551
|
+
.order_by(RequestLog.status.asc(), RequestLog.error_code.asc())
|
|
1552
|
+
)
|
|
1553
|
+
if filters.conditions:
|
|
1554
|
+
clause = and_(*filters.conditions)
|
|
1555
|
+
account_stmt = account_stmt.where(clause)
|
|
1556
|
+
model_stmt = model_stmt.where(clause)
|
|
1557
|
+
status_stmt = status_stmt.where(clause)
|
|
1558
|
+
if api_key_facet_filters.conditions:
|
|
1559
|
+
api_key_stmt = api_key_stmt.where(and_(*api_key_facet_filters.conditions))
|
|
1560
|
+
|
|
1561
|
+
account_rows = await self._session.execute(account_stmt)
|
|
1562
|
+
model_rows = await self._session.execute(model_stmt)
|
|
1563
|
+
api_key_rows = await self._session.execute(api_key_stmt)
|
|
1564
|
+
status_rows = await self._session.execute(status_stmt)
|
|
1565
|
+
|
|
1566
|
+
account_ids = [row[0] for row in account_rows.all() if row[0]]
|
|
1567
|
+
model_options = [(row[0], row[1]) for row in model_rows.all() if row[0]]
|
|
1568
|
+
api_key_ids = [row[0] for row in api_key_rows.all() if row[0]]
|
|
1569
|
+
status_values = [(row[0], row[1]) for row in status_rows.all() if row[0]]
|
|
1570
|
+
return account_ids, model_options, api_key_ids, status_values
|
|
1571
|
+
|
|
1572
|
+
async def _distinct_skip_scan(
|
|
1573
|
+
self,
|
|
1574
|
+
column: InstrumentedAttribute[str] | InstrumentedAttribute[str | None],
|
|
1575
|
+
conditions: list[ColumnElement[bool]],
|
|
1576
|
+
*,
|
|
1577
|
+
prefix_conditions: tuple[ColumnElement[bool], ...] = (),
|
|
1578
|
+
) -> list[str]:
|
|
1579
|
+
"""Loose-index-scan emulation: seed min(column), then min(column) >
|
|
1580
|
+
previous, one btree probe per distinct value. NULLs never seed or
|
|
1581
|
+
chain (min() skips them); empty strings are preserved — the legacy
|
|
1582
|
+
DISTINCT path only drops falsy values per facet, in the callers."""
|
|
1583
|
+
sqlite = self._session.get_bind().dialect.name == "sqlite"
|
|
1584
|
+
# SQLite can choose the deleted_at index for MIN(facet), rescanning
|
|
1585
|
+
# every live row per successor. Traverse the facet index first, then
|
|
1586
|
+
# check visibility with an equality probe for each candidate value.
|
|
1587
|
+
scan_conditions = prefix_conditions if sqlite else conditions
|
|
1588
|
+
seed = select(func.min(column).label("val")).where(*scan_conditions)
|
|
1589
|
+
skip = seed.cte("facet_skip", recursive=True)
|
|
1590
|
+
successor = select(func.min(column)).where(*scan_conditions, column > skip.c.val).scalar_subquery()
|
|
1591
|
+
skip = skip.union_all(select(successor).where(skip.c.val.is_not(None)))
|
|
1592
|
+
stmt = select(skip.c.val).where(skip.c.val.is_not(None)).order_by(skip.c.val.asc())
|
|
1593
|
+
if sqlite:
|
|
1594
|
+
visible = select(RequestLog.id).where(*conditions, column == skip.c.val).correlate(skip).exists()
|
|
1595
|
+
stmt = stmt.where(visible)
|
|
1596
|
+
rows = await self._session.execute(stmt)
|
|
1597
|
+
return [value for (value,) in rows.all() if value is not None]
|
|
1598
|
+
|
|
1599
|
+
async def _pair_facet_skip_scan(
|
|
1600
|
+
self,
|
|
1601
|
+
leading: InstrumentedAttribute[str] | InstrumentedAttribute[str | None],
|
|
1602
|
+
second: InstrumentedAttribute[str] | InstrumentedAttribute[str | None],
|
|
1603
|
+
conditions: list,
|
|
1604
|
+
) -> list[tuple[str, str | None]]:
|
|
1605
|
+
"""(leading, second) facet: skip-scan the leading column, then per
|
|
1606
|
+
value probe a `(value, NULL)` pair and skip-scan the non-NULL second
|
|
1607
|
+
values. NULL pair placement follows the backend's ORDER BY ASC NULL
|
|
1608
|
+
ordering (SQLite: first, PostgreSQL: last) so results match the
|
|
1609
|
+
legacy DISTINCT path exactly."""
|
|
1610
|
+
nulls_first = self._session.get_bind().dialect.name == "sqlite"
|
|
1611
|
+
pairs: list[tuple[str, str | None]] = []
|
|
1612
|
+
for value in await self._distinct_skip_scan(leading, conditions):
|
|
1613
|
+
if not value:
|
|
1614
|
+
# Legacy DISTINCT drops falsy leading values in Python.
|
|
1615
|
+
continue
|
|
1616
|
+
prefix = leading == value
|
|
1617
|
+
value_conditions = [*conditions, prefix]
|
|
1618
|
+
null_probe = select(RequestLog.id).where(*value_conditions, second.is_(None)).limit(1)
|
|
1619
|
+
has_null = (await self._session.execute(null_probe)).scalar_one_or_none() is not None
|
|
1620
|
+
second_values = await self._distinct_skip_scan(second, value_conditions, prefix_conditions=(prefix,))
|
|
1621
|
+
if has_null and nulls_first:
|
|
1622
|
+
pairs.append((value, None))
|
|
1623
|
+
pairs.extend((value, second_value) for second_value in second_values)
|
|
1624
|
+
if has_null and not nulls_first:
|
|
1625
|
+
pairs.append((value, None))
|
|
1626
|
+
return pairs
|
|
1627
|
+
|
|
1628
|
+
async def get_api_key_names_by_ids(self, api_key_ids: list[str]) -> dict[str, str]:
|
|
1629
|
+
unique_ids = sorted({key_id for key_id in api_key_ids if key_id})
|
|
1630
|
+
if not unique_ids:
|
|
1631
|
+
return {}
|
|
1632
|
+
result = await self._session.execute(select(ApiKey.id, ApiKey.name).where(ApiKey.id.in_(unique_ids)))
|
|
1633
|
+
return {key_id: name for key_id, name in result.all() if key_id and name}
|
|
1634
|
+
|
|
1635
|
+
async def get_api_key_details_by_ids(self, api_key_ids: list[str]) -> dict[str, tuple[str, str | None]]:
|
|
1636
|
+
unique_ids = sorted({key_id for key_id in api_key_ids if key_id})
|
|
1637
|
+
if not unique_ids:
|
|
1638
|
+
return {}
|
|
1639
|
+
result = await self._session.execute(
|
|
1640
|
+
select(ApiKey.id, ApiKey.name, ApiKey.key_prefix).where(ApiKey.id.in_(unique_ids))
|
|
1641
|
+
)
|
|
1642
|
+
return {key_id: (name, key_prefix) for key_id, name, key_prefix in result.all() if key_id and name}
|
|
1643
|
+
|
|
1644
|
+
def _build_filters(
|
|
1645
|
+
self,
|
|
1646
|
+
*,
|
|
1647
|
+
search: str | None = None,
|
|
1648
|
+
since: datetime | None = None,
|
|
1649
|
+
until: datetime | None = None,
|
|
1650
|
+
conversation_id: str | None = None,
|
|
1651
|
+
account_ids: list[str] | None = None,
|
|
1652
|
+
api_key_ids: list[str] | None = None,
|
|
1653
|
+
model_options: list[tuple[str, str | None]] | None = None,
|
|
1654
|
+
models: list[str] | None = None,
|
|
1655
|
+
reasoning_efforts: list[str] | None = None,
|
|
1656
|
+
include_success: bool = True,
|
|
1657
|
+
include_cancelled: bool = True,
|
|
1658
|
+
include_error_other: bool = True,
|
|
1659
|
+
error_codes_in: list[str] | None = None,
|
|
1660
|
+
error_codes_excluding: list[str] | None = None,
|
|
1661
|
+
exclude_soft_deleted: bool = False,
|
|
1662
|
+
include_sensitive_metadata: bool = True,
|
|
1663
|
+
include_account_identity: bool = True,
|
|
1664
|
+
include_api_key_identity: bool = True,
|
|
1665
|
+
) -> _RequestLogFilters:
|
|
1666
|
+
conditions = []
|
|
1667
|
+
if exclude_soft_deleted:
|
|
1668
|
+
conditions.append(RequestLog.deleted_at.is_(None))
|
|
1669
|
+
if since is not None:
|
|
1670
|
+
conditions.append(RequestLog.requested_at >= since)
|
|
1671
|
+
if until is not None:
|
|
1672
|
+
conditions.append(RequestLog.requested_at <= until)
|
|
1673
|
+
if conversation_id is not None:
|
|
1674
|
+
conditions.append(RequestLog.conversation_id == conversation_id)
|
|
1675
|
+
if account_ids:
|
|
1676
|
+
conditions.append(RequestLog.account_id.in_(account_ids))
|
|
1677
|
+
if api_key_ids:
|
|
1678
|
+
conditions.append(RequestLog.api_key_id.in_(api_key_ids))
|
|
1679
|
+
if model_options:
|
|
1680
|
+
pair_conditions = []
|
|
1681
|
+
for model, effort in model_options:
|
|
1682
|
+
base = (model or "").strip()
|
|
1683
|
+
if not base:
|
|
1684
|
+
continue
|
|
1685
|
+
if effort is None:
|
|
1686
|
+
pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort.is_(None)))
|
|
1687
|
+
else:
|
|
1688
|
+
pair_conditions.append(and_(RequestLog.model == base, RequestLog.reasoning_effort == effort))
|
|
1689
|
+
if pair_conditions:
|
|
1690
|
+
conditions.append(or_(*pair_conditions))
|
|
1691
|
+
else:
|
|
1692
|
+
if models:
|
|
1693
|
+
conditions.append(RequestLog.model.in_(models))
|
|
1694
|
+
if reasoning_efforts:
|
|
1695
|
+
conditions.append(RequestLog.reasoning_effort.in_(reasoning_efforts))
|
|
1696
|
+
|
|
1697
|
+
status_conditions = []
|
|
1698
|
+
if include_success:
|
|
1699
|
+
status_conditions.append(RequestLog.status == "success")
|
|
1700
|
+
if include_cancelled:
|
|
1701
|
+
status_conditions.append(RequestLog.status == CANCELLED_STATUS)
|
|
1702
|
+
if error_codes_in:
|
|
1703
|
+
status_conditions.append(and_(RequestLog.status == "error", RequestLog.error_code.in_(error_codes_in)))
|
|
1704
|
+
if include_error_other:
|
|
1705
|
+
error_clause = [RequestLog.status == "error"]
|
|
1706
|
+
if error_codes_excluding:
|
|
1707
|
+
error_clause.append(
|
|
1708
|
+
or_(
|
|
1709
|
+
RequestLog.error_code.is_(None),
|
|
1710
|
+
~RequestLog.error_code.in_(error_codes_excluding),
|
|
1711
|
+
)
|
|
1712
|
+
)
|
|
1713
|
+
status_conditions.append(and_(*error_clause))
|
|
1714
|
+
if status_conditions:
|
|
1715
|
+
conditions.append(or_(*status_conditions))
|
|
1716
|
+
if search:
|
|
1717
|
+
search_pattern = f"%{search}%"
|
|
1718
|
+
search_conditions = [
|
|
1719
|
+
RequestLog.account_id.ilike(search_pattern),
|
|
1720
|
+
RequestLog.request_id.ilike(search_pattern),
|
|
1721
|
+
RequestLog.model.ilike(search_pattern),
|
|
1722
|
+
RequestLog.reasoning_effort.ilike(search_pattern),
|
|
1723
|
+
RequestLog.source.ilike(search_pattern),
|
|
1724
|
+
RequestLog.status.ilike(search_pattern),
|
|
1725
|
+
RequestLog.error_code.ilike(search_pattern),
|
|
1726
|
+
RequestLog.error_message.ilike(search_pattern),
|
|
1727
|
+
cast(RequestLog.requested_at, String).ilike(search_pattern),
|
|
1728
|
+
cast(RequestLog.input_tokens, String).ilike(search_pattern),
|
|
1729
|
+
cast(RequestLog.output_tokens, String).ilike(search_pattern),
|
|
1730
|
+
cast(RequestLog.cached_input_tokens, String).ilike(search_pattern),
|
|
1731
|
+
cast(RequestLog.reasoning_tokens, String).ilike(search_pattern),
|
|
1732
|
+
cast(RequestLog.latency_ms, String).ilike(search_pattern),
|
|
1733
|
+
]
|
|
1734
|
+
if include_api_key_identity:
|
|
1735
|
+
# API-key ids and names are an api_keys:read surface; matching on
|
|
1736
|
+
# them would let a guest enumerate the key inventory.
|
|
1737
|
+
search_conditions.append(RequestLog.api_key_id.ilike(search_pattern))
|
|
1738
|
+
search_conditions.append(ApiKey.name.ilike(search_pattern))
|
|
1739
|
+
if include_sensitive_metadata:
|
|
1740
|
+
search_conditions.append(RequestLog.client_ip.ilike(search_pattern))
|
|
1741
|
+
if include_account_identity:
|
|
1742
|
+
# Account emails are redacted for principals without account
|
|
1743
|
+
# write access; matching on them would be a membership oracle.
|
|
1744
|
+
search_conditions.append(Account.email.ilike(search_pattern))
|
|
1745
|
+
conditions.append(or_(*search_conditions))
|
|
1746
|
+
return _RequestLogFilters(conditions=conditions, needs_related_search_joins=True)
|
|
1747
|
+
return _RequestLogFilters(conditions=conditions, needs_related_search_joins=False)
|
|
1748
|
+
|
|
1749
|
+
def _apply_related_search_joins(self, stmt, include_related_search_joins: bool):
|
|
1750
|
+
if not include_related_search_joins:
|
|
1751
|
+
return stmt
|
|
1752
|
+
return stmt.outerjoin(Account, Account.id == RequestLog.account_id).outerjoin(
|
|
1753
|
+
ApiKey,
|
|
1754
|
+
ApiKey.id == RequestLog.api_key_id,
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
async def _safe_rollback(session: AsyncSession) -> None:
|
|
1759
|
+
if not session.in_transaction():
|
|
1760
|
+
return
|
|
1761
|
+
try:
|
|
1762
|
+
with anyio.CancelScope(shield=True):
|
|
1763
|
+
await session.rollback()
|
|
1764
|
+
except BaseException:
|
|
1765
|
+
return
|