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.
Files changed (827) hide show
  1. app/__init__.py +10 -0
  2. app/admin_cli.py +494 -0
  3. app/cli.py +339 -0
  4. app/codex_sessions_retag.py +487 -0
  5. app/core/__init__.py +0 -0
  6. app/core/audit/__init__.py +0 -0
  7. app/core/audit/service.py +216 -0
  8. app/core/audit/types.py +94 -0
  9. app/core/auth/__init__.py +236 -0
  10. app/core/auth/api_key_cache.py +65 -0
  11. app/core/auth/dashboard_access.py +429 -0
  12. app/core/auth/dashboard_mode.py +171 -0
  13. app/core/auth/dashboard_session_ttl.py +74 -0
  14. app/core/auth/dashboard_users_cache.py +120 -0
  15. app/core/auth/dependencies.py +710 -0
  16. app/core/auth/external_identity.py +78 -0
  17. app/core/auth/guardian.py +374 -0
  18. app/core/auth/models.py +49 -0
  19. app/core/auth/providers/__init__.py +129 -0
  20. app/core/auth/providers/oidc.py +838 -0
  21. app/core/auth/providers/registry.py +118 -0
  22. app/core/auth/refresh.py +394 -0
  23. app/core/auth/step_up.py +117 -0
  24. app/core/auth/totp.py +81 -0
  25. app/core/balancer/__init__.py +71 -0
  26. app/core/balancer/logic.py +1612 -0
  27. app/core/balancer/rendezvous_hash.py +21 -0
  28. app/core/balancer/types.py +21 -0
  29. app/core/bootstrap.py +172 -0
  30. app/core/cache/invalidation.py +426 -0
  31. app/core/clients/__init__.py +0 -0
  32. app/core/clients/codex.py +996 -0
  33. app/core/clients/codex_version.py +187 -0
  34. app/core/clients/codex_version_snapshot.py +2 -0
  35. app/core/clients/files.py +534 -0
  36. app/core/clients/headers.py +29 -0
  37. app/core/clients/http.py +511 -0
  38. app/core/clients/model_fetcher.py +276 -0
  39. app/core/clients/native_egress.py +1304 -0
  40. app/core/clients/oauth.py +477 -0
  41. app/core/clients/proxy.py +6089 -0
  42. app/core/clients/proxy_websocket.py +1411 -0
  43. app/core/clients/rate_limit_reset_credits.py +412 -0
  44. app/core/clients/stream_errors.py +12 -0
  45. app/core/clients/thread_cache_identity.py +347 -0
  46. app/core/clients/usage.py +467 -0
  47. app/core/clock.py +170 -0
  48. app/core/config/__init__.py +0 -0
  49. app/core/config/background_jobs.py +73 -0
  50. app/core/config/context_window_overrides.py +166 -0
  51. app/core/config/dashboard_overrides.py +145 -0
  52. app/core/config/inheritable.py +47 -0
  53. app/core/config/key_fingerprint.py +115 -0
  54. app/core/config/settings.py +872 -0
  55. app/core/config/settings_cache.py +100 -0
  56. app/core/config/spool_retention.py +217 -0
  57. app/core/config/tiers.py +174 -0
  58. app/core/conversation_archive.py +568 -0
  59. app/core/crypto.py +37 -0
  60. app/core/draining.py +11 -0
  61. app/core/errors.py +365 -0
  62. app/core/exceptions.py +155 -0
  63. app/core/handlers/__init__.py +3 -0
  64. app/core/handlers/exceptions.py +478 -0
  65. app/core/http_protocol.py +176 -0
  66. app/core/http_protocol_httptools.py +204 -0
  67. app/core/ingress_limits.py +17 -0
  68. app/core/metadata_store.py +18 -0
  69. app/core/metrics/__init__.py +0 -0
  70. app/core/metrics/middleware.py +74 -0
  71. app/core/metrics/prometheus.py +604 -0
  72. app/core/middleware/__init__.py +25 -0
  73. app/core/middleware/api_firewall.py +111 -0
  74. app/core/middleware/app_version.py +37 -0
  75. app/core/middleware/dashboard_auth_proxy.py +55 -0
  76. app/core/middleware/dashboard_csrf.py +177 -0
  77. app/core/middleware/dashboard_gzip.py +41 -0
  78. app/core/middleware/dashboard_overrides.py +59 -0
  79. app/core/middleware/firewall_cache.py +91 -0
  80. app/core/middleware/inflight.py +126 -0
  81. app/core/middleware/multipart_content_encoding.py +129 -0
  82. app/core/middleware/path_rewrite.py +148 -0
  83. app/core/middleware/request_body_limit.py +196 -0
  84. app/core/middleware/request_decompression.py +209 -0
  85. app/core/middleware/request_id.py +72 -0
  86. app/core/middleware/required_capability_http.py +116 -0
  87. app/core/middleware/trusted_proxy_headers.py +36 -0
  88. app/core/multipart.py +237 -0
  89. app/core/multipart_fields.py +91 -0
  90. app/core/openai/__init__.py +0 -0
  91. app/core/openai/chat_requests.py +493 -0
  92. app/core/openai/chat_responses.py +931 -0
  93. app/core/openai/contracts.py +46 -0
  94. app/core/openai/exceptions.py +24 -0
  95. app/core/openai/host_models.py +14 -0
  96. app/core/openai/images.py +413 -0
  97. app/core/openai/message_coercion.py +376 -0
  98. app/core/openai/model_refresh_scheduler.py +423 -0
  99. app/core/openai/model_registry.py +997 -0
  100. app/core/openai/model_registry_store.py +548 -0
  101. app/core/openai/models.py +172 -0
  102. app/core/openai/parsing.py +97 -0
  103. app/core/openai/requests.py +1980 -0
  104. app/core/openai/strict_schema.py +199 -0
  105. app/core/openai/tool_call_safety.py +99 -0
  106. app/core/openai/v1_requests.py +187 -0
  107. app/core/plan_types.py +90 -0
  108. app/core/prestop.py +202 -0
  109. app/core/rate_limiter/__init__.py +0 -0
  110. app/core/rate_limiter/db_rate_limiter.py +215 -0
  111. app/core/request_locality.py +473 -0
  112. app/core/resilience/__init__.py +0 -0
  113. app/core/resilience/backpressure.py +57 -0
  114. app/core/resilience/bulkhead.py +202 -0
  115. app/core/resilience/circuit_breaker.py +150 -0
  116. app/core/resilience/degradation.py +55 -0
  117. app/core/resilience/loop_lag_monitor.py +60 -0
  118. app/core/resilience/memory_monitor.py +137 -0
  119. app/core/resilience/network_recovery.py +256 -0
  120. app/core/resilience/overload.py +98 -0
  121. app/core/resilience/toggles.py +114 -0
  122. app/core/retention/__init__.py +0 -0
  123. app/core/retention/job.py +240 -0
  124. app/core/retention/scheduler.py +69 -0
  125. app/core/runtime_logging.py +601 -0
  126. app/core/scheduling/__init__.py +0 -0
  127. app/core/scheduling/leader_election.py +993 -0
  128. app/core/scheduling/leader_election_handle.py +25 -0
  129. app/core/server.py +140 -0
  130. app/core/shutdown.py +326 -0
  131. app/core/socket_peer.py +22 -0
  132. app/core/startup.py +41 -0
  133. app/core/timeout_invariants.py +347 -0
  134. app/core/tracing/__init__.py +0 -0
  135. app/core/tracing/otel.py +207 -0
  136. app/core/types.py +6 -0
  137. app/core/upstream_proxy/__init__.py +13 -0
  138. app/core/upstream_proxy/cache.py +130 -0
  139. app/core/upstream_proxy/resolver.py +189 -0
  140. app/core/upstream_proxy/types.py +104 -0
  141. app/core/usage/__init__.py +407 -0
  142. app/core/usage/depletion.py +109 -0
  143. app/core/usage/live_hub.py +41 -0
  144. app/core/usage/live_snapshots.py +174 -0
  145. app/core/usage/logs.py +212 -0
  146. app/core/usage/metadata_scheduler.py +118 -0
  147. app/core/usage/models.py +54 -0
  148. app/core/usage/pricing.py +576 -0
  149. app/core/usage/pricing_catalog.py +241 -0
  150. app/core/usage/pricing_snapshot.json +821 -0
  151. app/core/usage/quota.py +133 -0
  152. app/core/usage/refresh_policy.py +21 -0
  153. app/core/usage/refresh_scheduler.py +713 -0
  154. app/core/usage/reset_credits_refresh_scheduler.py +435 -0
  155. app/core/usage/types.py +168 -0
  156. app/core/utils/__init__.py +0 -0
  157. app/core/utils/json_guards.py +18 -0
  158. app/core/utils/locks.py +33 -0
  159. app/core/utils/masking.py +20 -0
  160. app/core/utils/proxy_env.py +104 -0
  161. app/core/utils/request_id.py +65 -0
  162. app/core/utils/retry.py +77 -0
  163. app/core/utils/shared_future.py +172 -0
  164. app/core/utils/sse.py +286 -0
  165. app/core/utils/time.py +24 -0
  166. app/db/__init__.py +0 -0
  167. app/db/account_identity_lock.py +56 -0
  168. app/db/alembic/__init__.py +1 -0
  169. app/db/alembic/env.py +70 -0
  170. app/db/alembic/revision_ids.py +43 -0
  171. app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
  172. app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
  173. app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
  174. app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
  175. app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
  176. app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
  177. app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
  178. app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
  179. app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
  180. app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
  181. app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
  182. app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
  183. app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
  184. app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
  185. app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
  186. app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
  187. app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
  188. app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
  189. app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
  190. app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
  191. app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
  192. app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
  193. app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
  194. app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
  195. app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
  196. app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
  197. app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
  198. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
  199. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
  200. app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
  201. app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
  202. app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
  203. app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
  204. app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
  205. app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
  206. app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
  207. app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
  208. app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
  209. app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
  210. app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
  211. app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
  212. app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
  213. app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
  214. app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
  215. app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
  216. app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
  217. app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
  218. app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
  219. app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
  220. app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
  221. app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
  222. app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
  223. app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
  224. app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
  225. app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
  226. app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
  227. app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
  228. app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
  229. app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
  230. app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
  231. app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
  232. app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
  233. app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
  234. app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
  235. app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
  236. app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
  237. app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
  238. app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
  239. app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
  240. app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
  241. app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
  242. app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
  243. app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
  244. app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
  245. app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
  246. app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
  247. app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
  248. app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
  249. app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
  250. app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
  251. app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
  252. app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
  253. app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
  254. app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
  255. app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
  256. app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
  257. app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
  258. app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
  259. app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
  260. app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
  261. app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
  262. app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
  263. app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
  264. app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
  265. app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
  266. app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
  267. app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
  268. app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
  269. app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
  270. app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
  271. app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
  272. app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
  273. app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
  274. app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
  275. app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
  276. app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
  277. app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
  278. app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
  279. app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
  280. app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
  281. app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
  282. app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
  283. app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
  284. app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
  285. app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
  286. app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
  287. app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
  288. app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
  289. app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
  290. app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
  291. app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
  292. app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
  293. app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
  294. app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
  295. app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
  296. app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
  297. app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
  298. app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
  299. app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
  300. app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
  301. app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
  302. app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
  303. app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
  304. app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
  305. app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
  306. app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
  307. app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
  308. app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
  309. app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
  310. app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
  311. app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
  312. app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
  313. app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
  314. app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
  315. app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
  316. app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
  317. app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
  318. app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
  319. app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
  320. app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
  321. app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
  322. app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
  323. app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
  324. app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
  325. app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
  326. app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
  327. app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
  328. app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
  329. app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
  330. app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
  331. app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
  332. app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
  333. app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
  334. app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
  335. app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
  336. app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
  337. app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
  338. app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
  339. app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
  340. app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
  341. app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
  342. app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
  343. app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
  344. app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
  345. app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
  346. app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
  347. app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
  348. app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
  349. app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
  350. app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
  351. app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
  352. app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
  353. app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
  354. app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
  355. app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
  356. app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
  357. app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
  358. app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
  359. app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
  360. app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
  361. app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
  362. app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
  363. app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
  364. app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
  365. app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
  366. app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
  367. app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
  368. app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
  369. app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
  370. app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
  371. app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
  372. app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
  373. app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
  374. app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
  375. app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
  376. app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
  377. app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
  378. app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
  379. app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
  380. app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
  381. app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
  382. app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
  383. app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
  384. app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
  385. app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
  386. app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
  387. app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
  388. app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
  389. app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
  390. app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
  391. app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
  392. app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
  393. app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
  394. app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
  395. app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
  396. app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
  397. app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
  398. app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
  399. app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
  400. app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
  401. app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
  402. app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
  403. app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
  404. app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
  405. app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
  406. app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
  407. app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
  408. app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
  409. app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
  410. app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
  411. app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
  412. app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
  413. app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
  414. app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
  415. app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
  416. app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
  417. app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
  418. app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
  419. app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
  420. app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
  421. app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
  422. app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
  423. app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
  424. app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
  425. app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
  426. app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
  427. app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
  428. app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
  429. app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
  430. app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
  431. app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
  432. app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
  433. app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
  434. app/db/alembic/versions/__init__.py +1 -0
  435. app/db/backup.py +64 -0
  436. app/db/migrate.py +1249 -0
  437. app/db/migration_lock.py +160 -0
  438. app/db/migration_url.py +18 -0
  439. app/db/models.py +3100 -0
  440. app/db/recover.py +244 -0
  441. app/db/session.py +1295 -0
  442. app/db/snapshot.py +46 -0
  443. app/db/sqlite_lock_retry.py +190 -0
  444. app/db/sqlite_utils.py +549 -0
  445. app/dependencies.py +462 -0
  446. app/main.py +1240 -0
  447. app/modules/__init__.py +0 -0
  448. app/modules/accounts/__init__.py +0 -0
  449. app/modules/accounts/api.py +426 -0
  450. app/modules/accounts/auth_manager.py +1439 -0
  451. app/modules/accounts/background_repository.py +160 -0
  452. app/modules/accounts/deletion.py +540 -0
  453. app/modules/accounts/mappers.py +654 -0
  454. app/modules/accounts/refresh_claims.py +352 -0
  455. app/modules/accounts/repository.py +1679 -0
  456. app/modules/accounts/schemas.py +308 -0
  457. app/modules/accounts/service.py +809 -0
  458. app/modules/accounts/usage_rollup.py +486 -0
  459. app/modules/accounts/usage_rollup_scheduler.py +75 -0
  460. app/modules/accounts/usage_time_rollup.py +1037 -0
  461. app/modules/accounts/usage_time_rollup_read.py +522 -0
  462. app/modules/api_keys/__init__.py +1 -0
  463. app/modules/api_keys/api.py +344 -0
  464. app/modules/api_keys/last_used_coalescer.py +216 -0
  465. app/modules/api_keys/limit_windows.py +41 -0
  466. app/modules/api_keys/repository.py +1200 -0
  467. app/modules/api_keys/reset_scheduler.py +85 -0
  468. app/modules/api_keys/schemas.py +135 -0
  469. app/modules/api_keys/service.py +2123 -0
  470. app/modules/audit/__init__.py +0 -0
  471. app/modules/audit/api.py +88 -0
  472. app/modules/audit/repository.py +74 -0
  473. app/modules/audit/schemas.py +33 -0
  474. app/modules/audit/service.py +71 -0
  475. app/modules/auth_providers/__init__.py +0 -0
  476. app/modules/auth_providers/api.py +127 -0
  477. app/modules/auth_providers/config.py +343 -0
  478. app/modules/auth_providers/repository.py +101 -0
  479. app/modules/auth_providers/schemas.py +88 -0
  480. app/modules/auth_providers/seed.py +105 -0
  481. app/modules/auth_providers/service.py +289 -0
  482. app/modules/automations/__init__.py +1 -0
  483. app/modules/automations/api.py +369 -0
  484. app/modules/automations/repository.py +1896 -0
  485. app/modules/automations/scheduler.py +107 -0
  486. app/modules/automations/schemas.py +158 -0
  487. app/modules/automations/service.py +2548 -0
  488. app/modules/cache_isolation_probe/__init__.py +0 -0
  489. app/modules/cache_isolation_probe/api.py +188 -0
  490. app/modules/cache_isolation_probe/prefix.py +144 -0
  491. app/modules/cache_isolation_probe/schemas.py +94 -0
  492. app/modules/cache_isolation_probe/sender.py +229 -0
  493. app/modules/cache_isolation_probe/service.py +525 -0
  494. app/modules/conversation_archive/__init__.py +1 -0
  495. app/modules/conversation_archive/api.py +101 -0
  496. app/modules/conversation_archive/schemas.py +28 -0
  497. app/modules/conversation_archive/service.py +165 -0
  498. app/modules/dashboard/__init__.py +1 -0
  499. app/modules/dashboard/api.py +106 -0
  500. app/modules/dashboard/builders.py +107 -0
  501. app/modules/dashboard/repository.py +304 -0
  502. app/modules/dashboard/schemas.py +148 -0
  503. app/modules/dashboard/service.py +534 -0
  504. app/modules/dashboard/timeframes.py +19 -0
  505. app/modules/dashboard/weekly_pace.py +617 -0
  506. app/modules/dashboard_auth/__init__.py +1 -0
  507. app/modules/dashboard_auth/api.py +1344 -0
  508. app/modules/dashboard_auth/oidc_api.py +753 -0
  509. app/modules/dashboard_auth/oidc_flows.py +330 -0
  510. app/modules/dashboard_auth/repository.py +452 -0
  511. app/modules/dashboard_auth/schemas.py +232 -0
  512. app/modules/dashboard_auth/service.py +1384 -0
  513. app/modules/dashboard_roles/__init__.py +0 -0
  514. app/modules/dashboard_roles/api.py +64 -0
  515. app/modules/dashboard_roles/repository.py +54 -0
  516. app/modules/dashboard_roles/schemas.py +29 -0
  517. app/modules/dashboard_roles/seed.py +80 -0
  518. app/modules/dashboard_roles/service.py +142 -0
  519. app/modules/dashboard_users/__init__.py +0 -0
  520. app/modules/dashboard_users/api.py +278 -0
  521. app/modules/dashboard_users/break_glass.py +169 -0
  522. app/modules/dashboard_users/credentials.py +18 -0
  523. app/modules/dashboard_users/identity_resolver.py +665 -0
  524. app/modules/dashboard_users/repository.py +725 -0
  525. app/modules/dashboard_users/schemas.py +117 -0
  526. app/modules/dashboard_users/service.py +1054 -0
  527. app/modules/firewall/__init__.py +1 -0
  528. app/modules/firewall/api.py +78 -0
  529. app/modules/firewall/repository.py +51 -0
  530. app/modules/firewall/schemas.py +28 -0
  531. app/modules/firewall/service.py +93 -0
  532. app/modules/fleet/__init__.py +1 -0
  533. app/modules/fleet/api.py +177 -0
  534. app/modules/fleet/mappers.py +56 -0
  535. app/modules/fleet/observability.py +372 -0
  536. app/modules/fleet/schemas.py +119 -0
  537. app/modules/health/__init__.py +0 -0
  538. app/modules/health/api.py +246 -0
  539. app/modules/health/schemas.py +27 -0
  540. app/modules/limit_warmup/__init__.py +1 -0
  541. app/modules/limit_warmup/repository.py +159 -0
  542. app/modules/limit_warmup/service.py +1001 -0
  543. app/modules/model_sources/__init__.py +1 -0
  544. app/modules/model_sources/api.py +99 -0
  545. app/modules/model_sources/catalog.py +307 -0
  546. app/modules/model_sources/forwarding.py +1435 -0
  547. app/modules/model_sources/projection.py +55 -0
  548. app/modules/model_sources/repository.py +201 -0
  549. app/modules/model_sources/schemas.py +79 -0
  550. app/modules/model_sources/selection.py +166 -0
  551. app/modules/model_sources/service.py +236 -0
  552. app/modules/oauth/__init__.py +0 -0
  553. app/modules/oauth/api.py +101 -0
  554. app/modules/oauth/repository.py +238 -0
  555. app/modules/oauth/schemas.py +49 -0
  556. app/modules/oauth/service.py +1196 -0
  557. app/modules/oauth/templates/oauth_success.html +122 -0
  558. app/modules/proxy/__init__.py +0 -0
  559. app/modules/proxy/_load_balancer/__init__.py +1 -0
  560. app/modules/proxy/_load_balancer/error_rate.py +116 -0
  561. app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
  562. app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
  563. app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
  564. app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
  565. app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
  566. app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
  567. app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
  568. app/modules/proxy/_load_balancer/tunables.py +97 -0
  569. app/modules/proxy/_load_balancer/types.py +96 -0
  570. app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
  571. app/modules/proxy/_service/__init__.py +6 -0
  572. app/modules/proxy/_service/api_key_usage.py +745 -0
  573. app/modules/proxy/_service/clock_budget.py +38 -0
  574. app/modules/proxy/_service/codex_control.py +611 -0
  575. app/modules/proxy/_service/compact.py +2211 -0
  576. app/modules/proxy/_service/file_ops.py +749 -0
  577. app/modules/proxy/_service/http_bridge/__init__.py +3 -0
  578. app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
  579. app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
  580. app/modules/proxy/_service/http_bridge/activity.py +178 -0
  581. app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
  582. app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
  583. app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
  584. app/modules/proxy/_service/http_bridge/protocol.py +167 -0
  585. app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
  586. app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
  587. app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
  588. app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
  589. app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
  590. app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
  591. app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
  592. app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
  593. app/modules/proxy/_service/observability.py +410 -0
  594. app/modules/proxy/_service/rate_limit.py +361 -0
  595. app/modules/proxy/_service/realtime_live.py +655 -0
  596. app/modules/proxy/_service/refresh.py +154 -0
  597. app/modules/proxy/_service/request_log.py +585 -0
  598. app/modules/proxy/_service/response_create.py +930 -0
  599. app/modules/proxy/_service/streaming/__init__.py +3 -0
  600. app/modules/proxy/_service/streaming/helpers.py +1317 -0
  601. app/modules/proxy/_service/streaming/mixin.py +1100 -0
  602. app/modules/proxy/_service/streaming/protocol.py +32 -0
  603. app/modules/proxy/_service/streaming/retry.py +3762 -0
  604. app/modules/proxy/_service/support.py +2122 -0
  605. app/modules/proxy/_service/transcribe.py +434 -0
  606. app/modules/proxy/_service/warmup.py +483 -0
  607. app/modules/proxy/_service/websocket/__init__.py +3 -0
  608. app/modules/proxy/_service/websocket/helpers.py +2360 -0
  609. app/modules/proxy/_service/websocket/mixin.py +7285 -0
  610. app/modules/proxy/_service/websocket/protocol.py +74 -0
  611. app/modules/proxy/account_cache.py +229 -0
  612. app/modules/proxy/account_eligibility.py +87 -0
  613. app/modules/proxy/additional_model_limits.py +40 -0
  614. app/modules/proxy/affinity.py +830 -0
  615. app/modules/proxy/affinity_observation.py +101 -0
  616. app/modules/proxy/api.py +10254 -0
  617. app/modules/proxy/api_key_usage.py +113 -0
  618. app/modules/proxy/cap_partitioning.py +280 -0
  619. app/modules/proxy/capability_lineage.py +65 -0
  620. app/modules/proxy/capability_lineage_repository.py +82 -0
  621. app/modules/proxy/capability_routing.py +253 -0
  622. app/modules/proxy/continuity.py +112 -0
  623. app/modules/proxy/downstream_delivery.py +277 -0
  624. app/modules/proxy/durable_bridge_coordinator.py +1217 -0
  625. app/modules/proxy/durable_bridge_repository.py +4516 -0
  626. app/modules/proxy/durable_bridge_runtime.py +9 -0
  627. app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
  628. app/modules/proxy/fair_share.py +131 -0
  629. app/modules/proxy/file_pin_repository.py +210 -0
  630. app/modules/proxy/helpers.py +439 -0
  631. app/modules/proxy/http_bridge_event_batcher.py +613 -0
  632. app/modules/proxy/http_bridge_forwarding.py +797 -0
  633. app/modules/proxy/http_continuation.py +76 -0
  634. app/modules/proxy/images_observability.py +62 -0
  635. app/modules/proxy/images_service.py +1054 -0
  636. app/modules/proxy/load_balancer.py +3016 -0
  637. app/modules/proxy/rate_limit_cache.py +46 -0
  638. app/modules/proxy/replay_safety.py +1063 -0
  639. app/modules/proxy/repo_bundle.py +31 -0
  640. app/modules/proxy/request_policy.py +1054 -0
  641. app/modules/proxy/ring_membership.py +251 -0
  642. app/modules/proxy/schemas.py +345 -0
  643. app/modules/proxy/selection_errors.py +41 -0
  644. app/modules/proxy/service.py +2600 -0
  645. app/modules/proxy/source_admission.py +125 -0
  646. app/modules/proxy/source_dispatch.py +999 -0
  647. app/modules/proxy/sticky_repository.py +831 -0
  648. app/modules/proxy/thread_anchors.py +491 -0
  649. app/modules/proxy/tool_call_dedupe.py +802 -0
  650. app/modules/proxy/types.py +54 -0
  651. app/modules/proxy/work_admission.py +121 -0
  652. app/modules/quota_planner/__init__.py +1 -0
  653. app/modules/quota_planner/api.py +276 -0
  654. app/modules/quota_planner/logic.py +743 -0
  655. app/modules/quota_planner/repository.py +722 -0
  656. app/modules/quota_planner/scheduler.py +212 -0
  657. app/modules/quota_planner/schemas.py +102 -0
  658. app/modules/quota_planner/warmup.py +693 -0
  659. app/modules/rate_limit_reset_credits/__init__.py +0 -0
  660. app/modules/rate_limit_reset_credits/api.py +601 -0
  661. app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
  662. app/modules/rate_limit_reset_credits/store.py +114 -0
  663. app/modules/reports/__init__.py +1 -0
  664. app/modules/reports/api.py +134 -0
  665. app/modules/reports/cache.py +74 -0
  666. app/modules/reports/filters.py +27 -0
  667. app/modules/reports/repository.py +525 -0
  668. app/modules/reports/rollup.py +150 -0
  669. app/modules/reports/rollup_read.py +148 -0
  670. app/modules/reports/schemas.py +123 -0
  671. app/modules/reports/service.py +287 -0
  672. app/modules/reports/thread_identity.py +256 -0
  673. app/modules/request_logs/__init__.py +0 -0
  674. app/modules/request_logs/api.py +224 -0
  675. app/modules/request_logs/cost_backfill.py +154 -0
  676. app/modules/request_logs/mappers.py +97 -0
  677. app/modules/request_logs/repository.py +1765 -0
  678. app/modules/request_logs/schemas.py +145 -0
  679. app/modules/request_logs/service.py +348 -0
  680. app/modules/role_mappings/__init__.py +0 -0
  681. app/modules/role_mappings/api.py +206 -0
  682. app/modules/role_mappings/matching.py +55 -0
  683. app/modules/role_mappings/repository.py +106 -0
  684. app/modules/role_mappings/schemas.py +67 -0
  685. app/modules/role_mappings/service.py +334 -0
  686. app/modules/runtime/__init__.py +1 -0
  687. app/modules/runtime/api.py +18 -0
  688. app/modules/runtime/schemas.py +14 -0
  689. app/modules/runtime/service.py +218 -0
  690. app/modules/scim/__init__.py +1 -0
  691. app/modules/scim/api.py +194 -0
  692. app/modules/scim/dependencies.py +112 -0
  693. app/modules/scim/errors.py +77 -0
  694. app/modules/scim/management_api.py +156 -0
  695. app/modules/scim/repository.py +212 -0
  696. app/modules/scim/schemas.py +145 -0
  697. app/modules/scim/service.py +786 -0
  698. app/modules/scim/tokens.py +43 -0
  699. app/modules/settings/__init__.py +1 -0
  700. app/modules/settings/api.py +1677 -0
  701. app/modules/settings/repository.py +606 -0
  702. app/modules/settings/schemas.py +496 -0
  703. app/modules/settings/service.py +769 -0
  704. app/modules/shared/__init__.py +0 -0
  705. app/modules/shared/schemas.py +22 -0
  706. app/modules/sticky_sessions/__init__.py +1 -0
  707. app/modules/sticky_sessions/api.py +118 -0
  708. app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
  709. app/modules/sticky_sessions/schemas.py +78 -0
  710. app/modules/sticky_sessions/service.py +196 -0
  711. app/modules/telemetry/__init__.py +5 -0
  712. app/modules/telemetry/api.py +115 -0
  713. app/modules/telemetry/clients.py +62 -0
  714. app/modules/telemetry/consent.py +115 -0
  715. app/modules/telemetry/scheduler.py +85 -0
  716. app/modules/telemetry/schemas.py +163 -0
  717. app/modules/telemetry/sender.py +221 -0
  718. app/modules/telemetry/snapshot.py +490 -0
  719. app/modules/usage/__init__.py +0 -0
  720. app/modules/usage/additional_quota_keys.py +379 -0
  721. app/modules/usage/api.py +45 -0
  722. app/modules/usage/background_repository.py +147 -0
  723. app/modules/usage/builders.py +559 -0
  724. app/modules/usage/depletion_service.py +277 -0
  725. app/modules/usage/live_ingest.py +356 -0
  726. app/modules/usage/mappers.py +29 -0
  727. app/modules/usage/plan_downgrade_observations.py +488 -0
  728. app/modules/usage/repository.py +1890 -0
  729. app/modules/usage/schemas.py +68 -0
  730. app/modules/usage/service.py +102 -0
  731. app/modules/usage/updater.py +1488 -0
  732. app/static/assets/Trans-Cj-J9mVA.js +1 -0
  733. app/static/assets/access-page-DHzZiXZi.js +1 -0
  734. app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
  735. app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
  736. app/static/assets/account-status-DIsU5sUS.js +1 -0
  737. app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
  738. app/static/assets/accounts-page-Bbai8ift.js +3 -0
  739. app/static/assets/api-Dwa7XxPe.js +1 -0
  740. app/static/assets/api-client-dugX1Pti.js +55 -0
  741. app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
  742. app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
  743. app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
  744. app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
  745. app/static/assets/apis-page-BvXz9c6i.js +2 -0
  746. app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
  747. app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
  748. app/static/assets/automations-page-DQgNAJOR.js +3 -0
  749. app/static/assets/badge-DqMb9es0.js +1 -0
  750. app/static/assets/blur-email-HEvKagG2.js +1 -0
  751. app/static/assets/button-BjVmmiGW.js +1 -0
  752. app/static/assets/checkbox-apsl9vzk.js +1 -0
  753. app/static/assets/chevron-left-1d_pHVa5.js +1 -0
  754. app/static/assets/circle-alert-BS0xd_o1.js +1 -0
  755. app/static/assets/clsx-DB0hHKMi.js +1 -0
  756. app/static/assets/colors-BBy8GEtr.js +1 -0
  757. app/static/assets/copy-button-D7nAvOKR.js +1 -0
  758. app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
  759. app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
  760. app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
  761. app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
  762. app/static/assets/donut-chart-Bxc69cra.js +1 -0
  763. app/static/assets/download-kQteIcsB.js +1 -0
  764. app/static/assets/ellipsis-B_5OgIre.js +1 -0
  765. app/static/assets/empty-state-JcA7tc8m.js +1 -0
  766. app/static/assets/es2015-bMH8YKZT.js +41 -0
  767. app/static/assets/es6-B264ni6W.js +69 -0
  768. app/static/assets/external-link-UVAOgMQ0.js +1 -0
  769. app/static/assets/i18n-BWqZ9Ua2.js +1 -0
  770. app/static/assets/inbox-B_RM2s1J.js +1 -0
  771. app/static/assets/index-DIpd264Z.js +23 -0
  772. app/static/assets/index-DMeLKRJx.css +2 -0
  773. app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
  774. app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
  775. app/static/assets/iso-Bpe5Ihbb.js +1 -0
  776. app/static/assets/label-CGaLmLNf.js +1 -0
  777. app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
  778. app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
  779. app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
  780. app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
  781. app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
  782. app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
  783. app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
  784. app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
  785. app/static/assets/payload-CIYnGZz9.js +1 -0
  786. app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
  787. app/static/assets/report-chart-card-BXDliwcc.js +1 -0
  788. app/static/assets/reports-page-CGCJeMHG.js +3 -0
  789. app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
  790. app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  791. app/static/assets/search-HfhqwERt.js +1 -0
  792. app/static/assets/select-Cl33bz-g.js +1 -0
  793. app/static/assets/settings-page-mZj01HYl.js +2 -0
  794. app/static/assets/shim-CZwyM4Q7.js +1 -0
  795. app/static/assets/skeleton-C_EUc4Rp.js +1 -0
  796. app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
  797. app/static/assets/status-badge-CHHA5X6c.js +1 -0
  798. app/static/assets/switch-CzxgokcM.js +1 -0
  799. app/static/assets/table-DXaNSQ0G.js +1 -0
  800. app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
  801. app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
  802. app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
  803. app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
  804. app/static/assets/trash-D3KyWr1h.js +1 -0
  805. app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
  806. app/static/assets/use-accounts-PWlVGw3z.js +1 -0
  807. app/static/assets/use-auth-CuDlW3iC.js +1 -0
  808. app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
  809. app/static/assets/use-model-sources-Bul24JBu.js +1 -0
  810. app/static/assets/use-models-DUCPUuZg.js +1 -0
  811. app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
  812. app/static/assets/useTranslation--56nHhlH.js +1 -0
  813. app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
  814. app/static/assets/users-uIC3TIlE.js +1 -0
  815. app/static/assets/vendor-query-_bHZuxEx.js +1 -0
  816. app/static/assets/vendor-react-BVL0YZPW.js +9 -0
  817. app/static/favicon.svg +10 -0
  818. app/static/fonts/GeistSans-Variable.woff2 +0 -0
  819. app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  820. app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  821. app/static/index.html +29 -0
  822. codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
  823. codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
  824. codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
  825. codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
  826. codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
  827. 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