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