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,2123 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timedelta, timezone
9
+ from hashlib import sha256
10
+ from math import ceil
11
+ from typing import Protocol
12
+
13
+ from sqlalchemy.exc import IntegrityError, OperationalError
14
+
15
+ from app.core.auth.api_key_cache import get_api_key_cache
16
+ from app.core.cache.invalidation import NAMESPACE_API_KEY, get_cache_invalidation_poller
17
+ from app.core.clients.thread_cache_identity import (
18
+ THREAD_CACHE_IDENTITY_MODES,
19
+ normalize_thread_cache_identity_mode,
20
+ )
21
+ from app.core.usage.pricing import (
22
+ UsageTokens,
23
+ calculate_cost_from_usage,
24
+ get_pricing_for_model,
25
+ )
26
+ from app.core.usage.types import UsageWindowRow
27
+ from app.core.utils.time import to_utc_naive, utcnow
28
+ from app.db.models import Account, AccountStatus, ApiKey, ApiKeyLimit, LimitType, LimitWindow, ModelSource, UsageHistory
29
+ from app.db.session import sqlite_writer_section
30
+ from app.db.sqlite_lock_retry import should_retry_after_sqlite_lock
31
+ from app.modules.api_keys.last_used_coalescer import ApiKeyLastUsedCoalescer, get_api_key_last_used_coalescer
32
+ from app.modules.api_keys.limit_windows import advance_limit_reset, limit_window_delta, next_limit_reset
33
+ from app.modules.api_keys.repository import (
34
+ _UNSET,
35
+ ApiKeyTrendBucket,
36
+ ApiKeyUsageSummary,
37
+ ApiKeyUsageTotals,
38
+ ReservationResult,
39
+ UsageReservationData,
40
+ UsageReservationItemData,
41
+ _Unset,
42
+ )
43
+ from app.modules.usage.repository import UsageRepository
44
+
45
+ _SQLITE_BUSY_RETRY_ATTEMPTS = 4
46
+ _SQLITE_BUSY_RETRY_BASE_SECONDS = 0.1
47
+ _SPARKLINE_DAYS = 7
48
+ _DETAIL_BUCKET_SECONDS = 3600
49
+ API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET = 8_192
50
+ API_KEY_USAGE_RESERVATION_DEFAULT_INPUT_TOKENS = API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET
51
+ API_KEY_USAGE_RESERVATION_DEFAULT_OUTPUT_TOKENS = 2_048
52
+ _API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_MICRODOLLARS = 2_000_000
53
+ _API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_BASE_TOKENS = API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET * 2
54
+ TRAFFIC_CLASS_FOREGROUND = "foreground"
55
+ TRAFFIC_CLASS_OPPORTUNISTIC = "opportunistic"
56
+ _SUPPORTED_TRAFFIC_CLASSES = frozenset({TRAFFIC_CLASS_FOREGROUND, TRAFFIC_CLASS_OPPORTUNISTIC})
57
+ _SUPPORTED_TRANSPORT_POLICY_OVERRIDES = frozenset({"smart", "always_http", "always_websocket"})
58
+ _REASONING_POLICY_EXCLUSIVE_CONSTRAINT = "ck_api_keys_reasoning_policy_exclusive"
59
+
60
+
61
+ class ApiKeysRepositoryProtocol(Protocol):
62
+ async def create(self, row: ApiKey, *, commit: bool = True) -> ApiKey: ...
63
+
64
+ async def get_by_id(self, key_id: str) -> ApiKey | None: ...
65
+
66
+ async def get_for_limit_enforcement(self, key_id: str) -> ApiKey | None: ...
67
+
68
+ async def get_by_hash(self, key_hash: str) -> ApiKey | None: ...
69
+
70
+ async def list_all(self) -> list[ApiKey]: ...
71
+ async def list_usage_summary_by_key(self) -> dict[str, ApiKeyUsageSummary]: ...
72
+ async def get_usage_summary_by_key_id(self, key_id: str) -> ApiKeyUsageSummary: ...
73
+ async def get_limit_usage_value(
74
+ self,
75
+ key_id: str,
76
+ *,
77
+ limit_type: LimitType,
78
+ since: datetime,
79
+ until: datetime,
80
+ model_filter: str | None,
81
+ ) -> int: ...
82
+ async def list_accounts_by_ids(self, account_ids: list[str]) -> list[Account]: ...
83
+ async def list_model_sources_by_ids(self, source_ids: list[str]) -> list[ModelSource]: ...
84
+ async def list_all_accounts(self) -> list[Account]: ...
85
+
86
+ async def update(
87
+ self,
88
+ key_id: str,
89
+ *,
90
+ name: str | _Unset = ...,
91
+ allowed_models: str | None | _Unset = ...,
92
+ apply_to_codex_model: bool | _Unset = ...,
93
+ enforced_model: str | None | _Unset = ...,
94
+ enforced_reasoning_effort: str | None | _Unset = ...,
95
+ allowed_reasoning_efforts: str | None | _Unset = ...,
96
+ enforced_service_tier: str | None | _Unset = ...,
97
+ traffic_class: str | _Unset = ...,
98
+ transport_policy_override: str | None | _Unset = ...,
99
+ thread_cache_identity_override: str | None | _Unset = ...,
100
+ usage_sections: str | _Unset = ...,
101
+ account_assignment_scope_enabled: bool | _Unset = ...,
102
+ source_assignment_scope_enabled: bool | _Unset = ...,
103
+ expires_at: datetime | None | _Unset = ...,
104
+ is_active: bool | _Unset = ...,
105
+ key_hash: str | _Unset = ...,
106
+ key_prefix: str | _Unset = ...,
107
+ commit: bool = True,
108
+ ) -> ApiKey | None: ...
109
+
110
+ async def delete(self, key_id: str) -> bool: ...
111
+
112
+ async def commit(self) -> None: ...
113
+
114
+ async def rollback(self) -> None: ...
115
+
116
+ async def get_limits_by_key(self, key_id: str) -> list[ApiKeyLimit]: ...
117
+
118
+ async def replace_limits(self, key_id: str, limits: list[ApiKeyLimit]) -> list[ApiKeyLimit]: ...
119
+
120
+ async def upsert_limits(
121
+ self,
122
+ key_id: str,
123
+ limits: list[ApiKeyLimit],
124
+ *,
125
+ commit: bool = True,
126
+ preserve_matched_usage: bool = False,
127
+ ) -> list[ApiKeyLimit]: ...
128
+ async def replace_account_assignments(
129
+ self, key_id: str, account_ids: list[str], *, commit: bool = True
130
+ ) -> None: ...
131
+ async def replace_source_assignments(self, key_id: str, source_ids: list[str], *, commit: bool = True) -> None: ...
132
+
133
+ async def increment_limit_usage(
134
+ self,
135
+ key_id: str,
136
+ *,
137
+ model: str,
138
+ input_tokens: int,
139
+ output_tokens: int,
140
+ cost_microdollars: int,
141
+ ) -> None: ...
142
+
143
+ async def reset_limit(self, limit_id: int, *, expected_reset_at: datetime, new_reset_at: datetime) -> bool: ...
144
+
145
+ async def try_reserve_usage(
146
+ self,
147
+ limit_id: int,
148
+ *,
149
+ delta: int,
150
+ expected_reset_at: datetime,
151
+ ) -> ReservationResult: ...
152
+
153
+ async def adjust_reserved_usage(
154
+ self,
155
+ limit_id: int,
156
+ *,
157
+ delta: int,
158
+ expected_reset_at: datetime,
159
+ ) -> bool: ...
160
+
161
+ async def create_usage_reservation(
162
+ self,
163
+ reservation_id: str,
164
+ *,
165
+ key_id: str,
166
+ model: str,
167
+ items: list[UsageReservationItemData],
168
+ ) -> None: ...
169
+
170
+ async def get_usage_reservation(self, reservation_id: str) -> UsageReservationData | None: ...
171
+
172
+ async def transition_usage_reservation_status(
173
+ self,
174
+ reservation_id: str,
175
+ *,
176
+ expected_status: str,
177
+ new_status: str,
178
+ ) -> bool: ...
179
+
180
+ async def upsert_reservation_item_actual(
181
+ self,
182
+ reservation_id: str,
183
+ *,
184
+ item: UsageReservationItemData,
185
+ actual_delta: int,
186
+ ) -> None: ...
187
+
188
+ async def settle_usage_reservation(
189
+ self,
190
+ reservation_id: str,
191
+ *,
192
+ status: str,
193
+ input_tokens: int | None,
194
+ output_tokens: int | None,
195
+ cached_input_tokens: int | None,
196
+ cost_microdollars: int | None,
197
+ ) -> None: ...
198
+
199
+ async def touch_usage_reservation(self, reservation_id: str) -> bool: ...
200
+
201
+ async def trends_by_key(
202
+ self,
203
+ key_id: str,
204
+ since: datetime,
205
+ until: datetime,
206
+ bucket_seconds: int = 3600,
207
+ ) -> list[ApiKeyTrendBucket]: ...
208
+
209
+ async def usage_7d(
210
+ self,
211
+ key_id: str,
212
+ since: datetime,
213
+ until: datetime,
214
+ ) -> ApiKeyUsageTotals: ...
215
+
216
+
217
+ class ApiKeyNotFoundError(ValueError):
218
+ pass
219
+
220
+
221
+ class ApiKeyInvalidError(ValueError):
222
+ pass
223
+
224
+
225
+ class ApiKeyValidationError(ValueError):
226
+ """Raised when api_keys service input fails domain validation.
227
+
228
+ Subclasses ValueError so existing transitive callers that catch
229
+ ``ValueError`` continue to work, but lets API routes catch the
230
+ typed exception explicitly so unrelated programming errors that
231
+ happen to surface as ``ValueError`` are no longer silently
232
+ converted into 4xx client responses (#619).
233
+ """
234
+
235
+ pass
236
+
237
+
238
+ class ApiKeyRateLimitExceededError(ValueError):
239
+ def __init__(self, *, message: str, reset_at: datetime) -> None:
240
+ super().__init__(message)
241
+ self.reset_at = reset_at
242
+
243
+
244
+ def _ensure_optional_int_token_budget(field_name: str, value: int | None) -> None:
245
+ if value is None:
246
+ return
247
+ if isinstance(value, bool) or not isinstance(value, int):
248
+ raise TypeError(f"{field_name} must be an int or None")
249
+
250
+
251
+ @dataclass(frozen=True, slots=True)
252
+ class ApiKeyRequestUsageBudget:
253
+ input_tokens: int | None = None
254
+ output_tokens: int | None = None
255
+
256
+ def __post_init__(self) -> None:
257
+ _ensure_optional_int_token_budget("input_tokens", self.input_tokens)
258
+ _ensure_optional_int_token_budget("output_tokens", self.output_tokens)
259
+
260
+
261
+ @dataclass(frozen=True, slots=True)
262
+ class LimitRuleData:
263
+ id: int
264
+ limit_type: str
265
+ limit_window: str
266
+ max_value: int
267
+ current_value: int
268
+ model_filter: str | None
269
+ reset_at: datetime
270
+
271
+
272
+ @dataclass(frozen=True, slots=True)
273
+ class LimitRuleInput:
274
+ limit_type: str
275
+ limit_window: str
276
+ max_value: int
277
+ model_filter: str | None = None
278
+
279
+
280
+ @dataclass(frozen=True, slots=True)
281
+ class ApiKeyCreateData:
282
+ name: str
283
+ allowed_models: list[str] | None
284
+ apply_to_codex_model: bool = False
285
+ enforced_model: str | None = None
286
+ enforced_reasoning_effort: str | None = None
287
+ allowed_reasoning_efforts: list[str] | None = None
288
+ enforced_service_tier: str | None = None
289
+ traffic_class: str = TRAFFIC_CLASS_FOREGROUND
290
+ transport_policy_override: str | None = None
291
+ thread_cache_identity_override: str | None = None
292
+ usage_sections: str = "upstream_limits,account_pool_usage"
293
+ expires_at: datetime | None = None
294
+ assigned_account_ids: list[str] | None = None
295
+ assigned_source_ids: list[str] | None = None
296
+ limits: list[LimitRuleInput] = field(default_factory=list)
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class ApiKeyUpdateData:
301
+ name: str | None = None
302
+ name_set: bool = False
303
+ allowed_models: list[str] | None = None
304
+ allowed_models_set: bool = False
305
+ apply_to_codex_model: bool | None = None
306
+ apply_to_codex_model_set: bool = False
307
+ enforced_model: str | None = None
308
+ enforced_model_set: bool = False
309
+ enforced_reasoning_effort: str | None = None
310
+ enforced_reasoning_effort_set: bool = False
311
+ allowed_reasoning_efforts: list[str] | None = None
312
+ allowed_reasoning_efforts_set: bool = False
313
+ enforced_service_tier: str | None = None
314
+ enforced_service_tier_set: bool = False
315
+ traffic_class: str | None = None
316
+ traffic_class_set: bool = False
317
+ transport_policy_override: str | None = None
318
+ transport_policy_override_set: bool = False
319
+ thread_cache_identity_override: str | None = None
320
+ thread_cache_identity_override_set: bool = False
321
+ usage_sections: str | None = None
322
+ usage_sections_set: bool = False
323
+ expires_at: datetime | None = None
324
+ expires_at_set: bool = False
325
+ is_active: bool | None = None
326
+ is_active_set: bool = False
327
+ assigned_account_ids: list[str] | None = None
328
+ assigned_account_ids_set: bool = False
329
+ assigned_source_ids: list[str] | None = None
330
+ assigned_source_ids_set: bool = False
331
+ limits: list[LimitRuleInput] | None = None
332
+ limits_set: bool = False
333
+ reset_usage: bool = False
334
+
335
+
336
+ @dataclass(frozen=True, slots=True)
337
+ class ApiKeyData:
338
+ id: str
339
+ name: str
340
+ key_prefix: str
341
+ allowed_models: list[str] | None
342
+ enforced_model: str | None
343
+ enforced_reasoning_effort: str | None
344
+ enforced_service_tier: str | None
345
+ expires_at: datetime | None
346
+ is_active: bool
347
+ created_at: datetime
348
+ last_used_at: datetime | None
349
+ allowed_reasoning_efforts: list[str] | None = None
350
+ apply_to_codex_model: bool = False
351
+ traffic_class: str = TRAFFIC_CLASS_FOREGROUND
352
+ transport_policy_override: str | None = None
353
+ thread_cache_identity_override: str | None = None
354
+ usage_sections: str = "upstream_limits,account_pool_usage"
355
+ limits: list[LimitRuleData] = field(default_factory=list)
356
+ usage_summary: "ApiKeyUsageSummaryData | None" = None
357
+ account_assignment_scope_enabled: bool = False
358
+ source_assignment_scope_enabled: bool = False
359
+ assigned_account_ids: list[str] = field(default_factory=list)
360
+ assigned_source_ids: list[str] = field(default_factory=list)
361
+ pooled_credits: "PooledCreditData | None" = None
362
+
363
+
364
+ @dataclass(frozen=True, slots=True)
365
+ class ApiKeyCreatedData(ApiKeyData):
366
+ key: str = ""
367
+
368
+
369
+ @dataclass(frozen=True, slots=True)
370
+ class ApiKeyUsageSummaryData:
371
+ request_count: int
372
+ total_tokens: int
373
+ cached_input_tokens: int
374
+ total_cost_usd: float
375
+
376
+
377
+ @dataclass(frozen=True, slots=True)
378
+ class PooledCreditData:
379
+ remaining_percent_primary: float | None = None
380
+ remaining_percent_secondary: float | None = None
381
+ capacity_credits_primary: float = 0.0
382
+
383
+
384
+ def _compute_pooled_credits(
385
+ *,
386
+ assigned_account_ids: list[str],
387
+ all_accounts: list[Account],
388
+ primary_usage: dict[str, UsageHistory],
389
+ secondary_usage: dict[str, UsageHistory],
390
+ account_assignment_scope_enabled: bool = False,
391
+ ) -> PooledCreditData:
392
+ import app.core.usage as usage_core
393
+ from app.modules.usage.mappers import usage_history_to_window_row
394
+
395
+ if account_assignment_scope_enabled and not assigned_account_ids:
396
+ return PooledCreditData()
397
+
398
+ if assigned_account_ids:
399
+ requested_account_ids = set(assigned_account_ids)
400
+ else:
401
+ requested_account_ids = {a.id for a in all_accounts}
402
+
403
+ account_map = {
404
+ a.id: a
405
+ for a in all_accounts
406
+ if a.id in requested_account_ids and a.status not in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED)
407
+ }
408
+ account_ids = set(account_map)
409
+
410
+ primary_rows_raw = [
411
+ usage_history_to_window_row(entry) for entry in primary_usage.values() if entry.account_id in account_ids
412
+ ]
413
+ secondary_rows_raw = [
414
+ usage_history_to_window_row(entry) for entry in secondary_usage.values() if entry.account_id in account_ids
415
+ ]
416
+
417
+ primary_rows, secondary_rows = usage_core.normalize_weekly_only_rows(
418
+ primary_rows_raw,
419
+ secondary_rows_raw,
420
+ )
421
+ now_epoch = int(time.time())
422
+ # A live primary sample is one whose reset has not elapsed; when none
423
+ # exists (upstream stopped reporting the short window), the pooled
424
+ # primary bar must read absent rather than frozen or optimistic.
425
+ has_live_primary = any(row.reset_at is None or row.reset_at > now_epoch for row in primary_rows)
426
+ primary_rows = usage_core.expire_elapsed_window_rows(primary_rows, now_epoch=now_epoch)
427
+ # Secondary (weekly) rows pool raw: upstream still reports long windows,
428
+ # so an elapsed sample is transient staleness the next refresh rewrites —
429
+ # zeroing it would briefly report an exhausted weekly pool as 100% free.
430
+ primary_rows = _seed_missing_usage_rows(primary_rows, account_ids)
431
+ secondary_rows = _seed_missing_usage_rows(secondary_rows, account_ids)
432
+
433
+ primary_summary = usage_core.summarize_usage_window(primary_rows, account_map, "primary")
434
+ secondary_summary = usage_core.summarize_usage_window(secondary_rows, account_map, "secondary")
435
+
436
+ primary_remaining = usage_core.remaining_percent_from_used(primary_summary.used_percent)
437
+ secondary_remaining = usage_core.remaining_percent_from_used(secondary_summary.used_percent)
438
+
439
+ if primary_summary.capacity_credits == 0.0 or not has_live_primary:
440
+ primary_remaining = None
441
+
442
+ return PooledCreditData(
443
+ remaining_percent_primary=primary_remaining,
444
+ remaining_percent_secondary=secondary_remaining,
445
+ capacity_credits_primary=primary_summary.capacity_credits,
446
+ )
447
+
448
+
449
+ def _seed_missing_usage_rows(
450
+ rows: list[UsageWindowRow],
451
+ account_ids: set[str],
452
+ ) -> list[UsageWindowRow]:
453
+ present_account_ids = {row.account_id for row in rows}
454
+ missing_account_ids = account_ids - present_account_ids
455
+ if not missing_account_ids:
456
+ return rows
457
+ return rows + [
458
+ UsageWindowRow(account_id=account_id, used_percent=0.0) for account_id in sorted(missing_account_ids)
459
+ ]
460
+
461
+
462
+ @dataclass(frozen=True, slots=True)
463
+ class ApiKeyUsageReservationData:
464
+ reservation_id: str
465
+ key_id: str
466
+ model: str
467
+ has_applicable_limits: bool = True
468
+
469
+
470
+ class ApiKeysService:
471
+ def __init__(
472
+ self,
473
+ repository: ApiKeysRepositoryProtocol,
474
+ usage_repository: UsageRepository | None = None,
475
+ *,
476
+ last_used_coalescer: ApiKeyLastUsedCoalescer | None = None,
477
+ ) -> None:
478
+ self._repository = repository
479
+ self._usage_repository = usage_repository
480
+ self._last_used_coalescer = last_used_coalescer or get_api_key_last_used_coalescer()
481
+
482
+ async def create_key(self, payload: ApiKeyCreateData) -> ApiKeyCreatedData:
483
+ _validate_unique_limit_rule_identities(payload.limits)
484
+ now = utcnow()
485
+ expires_at = _normalize_expires_at(payload.expires_at)
486
+ plain_key = _generate_plain_key()
487
+ normalized_allowed_models = _normalize_allowed_models(payload.allowed_models)
488
+ assigned_account_ids = await self._resolve_assigned_account_ids(payload.assigned_account_ids)
489
+ assigned_source_ids = await self._resolve_assigned_source_ids(payload.assigned_source_ids)
490
+ enforced_model = _normalize_model_slug(payload.enforced_model)
491
+ enforced_reasoning_effort = _normalize_reasoning_effort(payload.enforced_reasoning_effort)
492
+ allowed_reasoning_efforts = _normalize_allowed_reasoning_efforts(payload.allowed_reasoning_efforts)
493
+ enforced_service_tier = _normalize_service_tier(payload.enforced_service_tier)
494
+ traffic_class = _normalize_traffic_class(payload.traffic_class)
495
+ transport_policy_override = _normalize_transport_policy_override(payload.transport_policy_override)
496
+ thread_cache_identity_override = _normalize_thread_cache_identity_override(
497
+ payload.thread_cache_identity_override
498
+ )
499
+ usage_sections = _normalize_usage_sections(payload.usage_sections)
500
+ _validate_model_enforcement(enforced_model=enforced_model, allowed_models=normalized_allowed_models)
501
+ _validate_reasoning_effort_policy(
502
+ enforced_reasoning_effort=enforced_reasoning_effort,
503
+ allowed_reasoning_efforts=allowed_reasoning_efforts,
504
+ )
505
+ row = ApiKey(
506
+ id=str(__import__("uuid").uuid4()),
507
+ name=_normalize_name(payload.name),
508
+ key_hash=_hash_key(plain_key),
509
+ key_prefix=plain_key[:15],
510
+ allowed_models=_serialize_allowed_models(normalized_allowed_models),
511
+ apply_to_codex_model=bool(payload.apply_to_codex_model),
512
+ enforced_model=enforced_model,
513
+ enforced_reasoning_effort=enforced_reasoning_effort,
514
+ allowed_reasoning_efforts=_serialize_allowed_reasoning_efforts(allowed_reasoning_efforts),
515
+ enforced_service_tier=enforced_service_tier,
516
+ account_assignment_scope_enabled=bool(assigned_account_ids),
517
+ source_assignment_scope_enabled=bool(assigned_source_ids),
518
+ traffic_class=traffic_class,
519
+ transport_policy_override=transport_policy_override,
520
+ thread_cache_identity_override=thread_cache_identity_override,
521
+ usage_sections=usage_sections,
522
+ expires_at=expires_at,
523
+ is_active=True,
524
+ created_at=now,
525
+ last_used_at=None,
526
+ )
527
+ try:
528
+ created = await self._repository.create(row, commit=False)
529
+
530
+ if assigned_account_ids:
531
+ await self._repository.replace_account_assignments(created.id, assigned_account_ids, commit=False)
532
+ if assigned_source_ids:
533
+ await self._repository.replace_source_assignments(created.id, assigned_source_ids, commit=False)
534
+
535
+ if payload.limits:
536
+ limit_rows = [_limit_input_to_row(li, created.id, now) for li in payload.limits]
537
+ await self._repository.upsert_limits(created.id, limit_rows, commit=False)
538
+
539
+ await self._repository.commit()
540
+ except Exception as exc:
541
+ await self._repository.rollback()
542
+ if isinstance(exc, IntegrityError) and _is_reasoning_policy_constraint_error(exc):
543
+ raise ApiKeyValidationError(
544
+ "enforced_reasoning_effort and allowed_reasoning_efforts cannot be configured together"
545
+ ) from exc
546
+ raise
547
+
548
+ created = await self._repository.get_by_id(created.id)
549
+ if created is None:
550
+ raise ValueError("Failed to create API key")
551
+
552
+ return _to_created_data(_to_api_key_data(created), plain_key)
553
+
554
+ async def list_keys(self) -> list[ApiKeyData]:
555
+ rows = await self._repository.list_all()
556
+ usage_summary_by_key = await self._repository.list_usage_summary_by_key()
557
+
558
+ pooled_by_key: dict[str, PooledCreditData] = {}
559
+ if self._usage_repository is not None:
560
+ assigned_ids_by_key = {
561
+ row.id: [a.account_id for a in getattr(row, "account_assignments", [])] for row in rows
562
+ }
563
+ needs_all_accounts = any(
564
+ not assigned_ids_by_key[row.id] and not row.account_assignment_scope_enabled for row in rows
565
+ )
566
+ if needs_all_accounts:
567
+ all_accounts = await self._repository.list_all_accounts()
568
+ primary_usage = await self._usage_repository.latest_by_account("primary")
569
+ secondary_usage = await self._usage_repository.latest_by_account("secondary")
570
+ else:
571
+ all_account_ids = sorted({account_id for ids in assigned_ids_by_key.values() for account_id in ids})
572
+ if all_account_ids:
573
+ all_accounts = await self._repository.list_accounts_by_ids(all_account_ids)
574
+ primary_usage = await self._usage_repository.latest_by_account(
575
+ "primary", account_ids=all_account_ids
576
+ )
577
+ secondary_usage = await self._usage_repository.latest_by_account(
578
+ "secondary",
579
+ account_ids=all_account_ids,
580
+ )
581
+ else:
582
+ all_accounts = []
583
+ primary_usage = {}
584
+ secondary_usage = {}
585
+ for row in rows:
586
+ pooled_by_key[row.id] = _compute_pooled_credits(
587
+ assigned_account_ids=assigned_ids_by_key[row.id],
588
+ all_accounts=all_accounts,
589
+ primary_usage=primary_usage,
590
+ secondary_usage=secondary_usage,
591
+ account_assignment_scope_enabled=row.account_assignment_scope_enabled,
592
+ )
593
+
594
+ return [
595
+ _to_api_key_data(
596
+ row,
597
+ usage_summary=_to_usage_summary_data(usage_summary_by_key.get(row.id)),
598
+ pooled_credits=pooled_by_key.get(row.id),
599
+ )
600
+ for row in rows
601
+ ]
602
+
603
+ async def update_key(
604
+ self,
605
+ key_id: str,
606
+ payload: ApiKeyUpdateData,
607
+ *,
608
+ _retry_attempt: int = 0,
609
+ ) -> ApiKeyData:
610
+ expires_at = _normalize_expires_at(payload.expires_at) if payload.expires_at_set else None
611
+ existing = await self._repository.get_by_id(key_id)
612
+ if existing is None:
613
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
614
+
615
+ if payload.allowed_models_set:
616
+ allowed_models = _normalize_allowed_models(payload.allowed_models)
617
+ else:
618
+ allowed_models = None
619
+ if payload.assigned_account_ids_set:
620
+ assigned_account_ids = await self._resolve_assigned_account_ids(payload.assigned_account_ids)
621
+ account_assignment_scope_enabled: bool | _Unset = bool(assigned_account_ids)
622
+ else:
623
+ assigned_account_ids = None
624
+ account_assignment_scope_enabled = _UNSET
625
+ if payload.assigned_source_ids_set:
626
+ assigned_source_ids = await self._resolve_assigned_source_ids(payload.assigned_source_ids)
627
+ source_assignment_scope_enabled: bool | _Unset = bool(assigned_source_ids)
628
+ else:
629
+ assigned_source_ids = None
630
+ source_assignment_scope_enabled = _UNSET
631
+
632
+ if payload.enforced_model_set:
633
+ enforced_model = _normalize_model_slug(payload.enforced_model)
634
+ else:
635
+ enforced_model = None
636
+
637
+ apply_to_codex_model: bool | _Unset
638
+ if payload.apply_to_codex_model_set:
639
+ if payload.apply_to_codex_model is None:
640
+ apply_to_codex_model = _UNSET
641
+ else:
642
+ apply_to_codex_model = payload.apply_to_codex_model
643
+ else:
644
+ apply_to_codex_model = _UNSET
645
+
646
+ if payload.enforced_reasoning_effort_set:
647
+ enforced_reasoning_effort = _normalize_reasoning_effort(payload.enforced_reasoning_effort)
648
+ else:
649
+ enforced_reasoning_effort = None
650
+
651
+ if payload.allowed_reasoning_efforts_set:
652
+ allowed_reasoning_efforts = _normalize_allowed_reasoning_efforts(payload.allowed_reasoning_efforts)
653
+ else:
654
+ allowed_reasoning_efforts = None
655
+
656
+ if payload.enforced_service_tier_set:
657
+ enforced_service_tier = _normalize_service_tier(payload.enforced_service_tier)
658
+ else:
659
+ enforced_service_tier = None
660
+
661
+ traffic_class_update: str | _Unset = _UNSET
662
+ if payload.traffic_class_set:
663
+ traffic_class_update = _normalize_traffic_class(payload.traffic_class)
664
+ transport_policy_override_update: str | None | _Unset = _UNSET
665
+ if payload.transport_policy_override_set:
666
+ transport_policy_override_update = _normalize_transport_policy_override(payload.transport_policy_override)
667
+ thread_cache_identity_override_update: str | None | _Unset = _UNSET
668
+ if payload.thread_cache_identity_override_set:
669
+ thread_cache_identity_override_update = _normalize_thread_cache_identity_override(
670
+ payload.thread_cache_identity_override
671
+ )
672
+ usage_sections: str | _Unset = _UNSET
673
+ if payload.usage_sections_set:
674
+ usage_sections = _normalize_usage_sections(payload.usage_sections)
675
+
676
+ if payload.allowed_models_set or payload.enforced_model_set:
677
+ effective_allowed_models = (
678
+ allowed_models if payload.allowed_models_set else _deserialize_allowed_models(existing.allowed_models)
679
+ )
680
+ effective_enforced_model = (
681
+ enforced_model if payload.enforced_model_set else _normalize_model_slug(existing.enforced_model)
682
+ )
683
+ _validate_model_enforcement(
684
+ enforced_model=effective_enforced_model,
685
+ allowed_models=effective_allowed_models,
686
+ )
687
+
688
+ if payload.enforced_reasoning_effort_set or payload.allowed_reasoning_efforts_set:
689
+ effective_enforced_reasoning_effort = (
690
+ enforced_reasoning_effort
691
+ if payload.enforced_reasoning_effort_set
692
+ else _normalize_reasoning_effort_lenient(existing.enforced_reasoning_effort)
693
+ )
694
+ effective_allowed_reasoning_efforts = (
695
+ allowed_reasoning_efforts
696
+ if payload.allowed_reasoning_efforts_set
697
+ else _deserialize_allowed_reasoning_efforts(existing.allowed_reasoning_efforts)
698
+ )
699
+ _validate_reasoning_effort_policy(
700
+ enforced_reasoning_effort=effective_enforced_reasoning_effort,
701
+ allowed_reasoning_efforts=effective_allowed_reasoning_efforts,
702
+ )
703
+
704
+ limit_rows: list[ApiKeyLimit] | None = None
705
+ if payload.limits_set:
706
+ now = utcnow()
707
+ existing_limits = await self._repository.get_limits_by_key(key_id)
708
+ submitted_limits = payload.limits or []
709
+ limit_rows = await _build_limit_rows_for_update(
710
+ key_id=key_id,
711
+ now=now,
712
+ submitted_limits=submitted_limits,
713
+ existing_limits=existing_limits,
714
+ reset_usage=payload.reset_usage,
715
+ repository=self._repository,
716
+ )
717
+ elif payload.reset_usage:
718
+ now = utcnow()
719
+ existing_limits = await self._repository.get_limits_by_key(key_id)
720
+ limit_rows = _build_reset_limit_rows(key_id=key_id, now=now, existing_limits=existing_limits)
721
+
722
+ try:
723
+ row = await self._repository.update(
724
+ key_id,
725
+ name=_normalize_name(payload.name or "") if payload.name_set else _UNSET,
726
+ allowed_models=_serialize_allowed_models(allowed_models) if payload.allowed_models_set else _UNSET,
727
+ apply_to_codex_model=apply_to_codex_model,
728
+ enforced_model=enforced_model if payload.enforced_model_set else _UNSET,
729
+ enforced_reasoning_effort=(
730
+ enforced_reasoning_effort if payload.enforced_reasoning_effort_set else _UNSET
731
+ ),
732
+ allowed_reasoning_efforts=(
733
+ _serialize_allowed_reasoning_efforts(allowed_reasoning_efforts)
734
+ if payload.allowed_reasoning_efforts_set
735
+ else _UNSET
736
+ ),
737
+ enforced_service_tier=(enforced_service_tier if payload.enforced_service_tier_set else _UNSET),
738
+ traffic_class=traffic_class_update,
739
+ transport_policy_override=transport_policy_override_update,
740
+ thread_cache_identity_override=thread_cache_identity_override_update,
741
+ usage_sections=usage_sections,
742
+ account_assignment_scope_enabled=account_assignment_scope_enabled,
743
+ source_assignment_scope_enabled=source_assignment_scope_enabled,
744
+ expires_at=expires_at if payload.expires_at_set else _UNSET,
745
+ is_active=(payload.is_active if payload.is_active_set and payload.is_active is not None else _UNSET),
746
+ commit=False,
747
+ )
748
+ if row is None:
749
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
750
+
751
+ if payload.assigned_account_ids_set:
752
+ assert assigned_account_ids is not None
753
+ await self._repository.replace_account_assignments(key_id, assigned_account_ids, commit=False)
754
+ if payload.assigned_source_ids_set:
755
+ assert assigned_source_ids is not None
756
+ await self._repository.replace_source_assignments(key_id, assigned_source_ids, commit=False)
757
+
758
+ if limit_rows is not None:
759
+ await self._repository.upsert_limits(
760
+ key_id,
761
+ limit_rows,
762
+ commit=False,
763
+ preserve_matched_usage=not payload.reset_usage,
764
+ )
765
+
766
+ await self._repository.commit()
767
+ except Exception as exc:
768
+ await self._repository.rollback()
769
+ if await should_retry_after_sqlite_lock(
770
+ exc,
771
+ what="update_key",
772
+ attempt=_retry_attempt,
773
+ max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
774
+ base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
775
+ ):
776
+ return await self.update_key(key_id, payload, _retry_attempt=_retry_attempt + 1)
777
+ if isinstance(exc, IntegrityError) and _is_reasoning_policy_constraint_error(exc):
778
+ raise ApiKeyValidationError(
779
+ "enforced_reasoning_effort and allowed_reasoning_efforts cannot be configured together"
780
+ ) from exc
781
+ raise
782
+
783
+ if (
784
+ payload.assigned_account_ids_set
785
+ or payload.assigned_source_ids_set
786
+ or limit_rows is not None
787
+ or payload.name_set
788
+ or payload.allowed_models_set
789
+ or payload.apply_to_codex_model_set
790
+ or payload.enforced_model_set
791
+ or payload.enforced_reasoning_effort_set
792
+ or payload.allowed_reasoning_efforts_set
793
+ or payload.enforced_service_tier_set
794
+ or payload.traffic_class_set
795
+ or payload.transport_policy_override_set
796
+ or payload.thread_cache_identity_override_set
797
+ or payload.usage_sections_set
798
+ or payload.expires_at_set
799
+ or payload.is_active_set
800
+ ):
801
+ row = await self._repository.get_by_id(key_id)
802
+ if row is None:
803
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
804
+
805
+ await get_api_key_cache().invalidate(row.key_hash)
806
+ poller = get_cache_invalidation_poller()
807
+ if poller is not None:
808
+ await poller.bump(NAMESPACE_API_KEY)
809
+ return _to_api_key_data(row)
810
+
811
+ async def _resolve_assigned_account_ids(self, account_ids: list[str] | None) -> list[str]:
812
+ normalized_account_ids = _normalize_assigned_account_ids(account_ids)
813
+ existing_accounts = await self._repository.list_accounts_by_ids(normalized_account_ids)
814
+ existing_account_ids = {account.id for account in existing_accounts}
815
+ missing_account_ids = [
816
+ account_id for account_id in normalized_account_ids if account_id not in existing_account_ids
817
+ ]
818
+ if missing_account_ids:
819
+ missing = ", ".join(missing_account_ids)
820
+ raise ApiKeyValidationError(f"Unknown account ids: {missing}")
821
+ return normalized_account_ids
822
+
823
+ async def _resolve_assigned_source_ids(self, source_ids: list[str] | None) -> list[str]:
824
+ normalized_source_ids = _normalize_assigned_source_ids(source_ids)
825
+ if not normalized_source_ids:
826
+ return []
827
+ existing_sources = await self._repository.list_model_sources_by_ids(normalized_source_ids)
828
+ existing_source_ids = {source.id for source in existing_sources}
829
+ missing_source_ids = [source_id for source_id in normalized_source_ids if source_id not in existing_source_ids]
830
+ if missing_source_ids:
831
+ missing = ", ".join(missing_source_ids)
832
+ raise ApiKeyValidationError(f"Unknown model source ids: {missing}")
833
+ return normalized_source_ids
834
+
835
+ async def delete_key(self, key_id: str) -> None:
836
+ row = await self._repository.get_by_id(key_id)
837
+ if row is None:
838
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
839
+ deleted = await self._repository.delete(key_id)
840
+ if not deleted:
841
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
842
+ await get_api_key_cache().invalidate(row.key_hash)
843
+ poller = get_cache_invalidation_poller()
844
+ if poller is not None:
845
+ await poller.bump(NAMESPACE_API_KEY)
846
+
847
+ async def regenerate_key(self, key_id: str) -> ApiKeyCreatedData:
848
+ row = await self._repository.get_by_id(key_id)
849
+ if row is None:
850
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
851
+ old_key_hash = row.key_hash
852
+ plain_key = _generate_plain_key()
853
+ updated = await self._repository.update(
854
+ key_id,
855
+ key_hash=_hash_key(plain_key),
856
+ key_prefix=plain_key[:15],
857
+ )
858
+ if updated is None:
859
+ raise ApiKeyNotFoundError(f"API key not found: {key_id}")
860
+ await get_api_key_cache().invalidate(old_key_hash)
861
+ poller = get_cache_invalidation_poller()
862
+ if poller is not None:
863
+ await poller.bump(NAMESPACE_API_KEY)
864
+ return _to_created_data(_to_api_key_data(updated), plain_key)
865
+
866
+ async def validate_key(self, plain_key: str) -> ApiKeyData:
867
+ if not plain_key:
868
+ raise ApiKeyInvalidError("Missing API key in Authorization header")
869
+
870
+ key_hash = _hash_key(plain_key)
871
+ now = utcnow()
872
+ row = _ensure_valid_api_key_row(await self._repository.get_by_hash(key_hash))
873
+ if row.expires_at is not None and row.expires_at < now:
874
+ raise ApiKeyInvalidError("API key has expired")
875
+ limits_reset = await _lazy_reset_expired_limits(self._repository, row.limits, now=now)
876
+ refreshed = _ensure_valid_api_key_row(await self._repository.get_by_hash(key_hash)) if limits_reset else row
877
+ if refreshed.expires_at is not None and refreshed.expires_at < now:
878
+ raise ApiKeyInvalidError("API key has expired")
879
+ return _to_api_key_data(refreshed)
880
+
881
+ async def get_key_by_id(self, key_id: str) -> ApiKeyData:
882
+ now = utcnow()
883
+ row = _ensure_valid_api_key_row(await self._repository.get_by_id(key_id))
884
+ if row.expires_at is not None and row.expires_at < now:
885
+ raise ApiKeyInvalidError("API key has expired")
886
+ return _to_api_key_data(row)
887
+
888
+ async def enforce_limits_for_request(
889
+ self,
890
+ key_id: str,
891
+ *,
892
+ request_model: str | None,
893
+ request_service_tier: str | None = None,
894
+ request_usage_budget: ApiKeyRequestUsageBudget | None = None,
895
+ ) -> ApiKeyUsageReservationData | None:
896
+ for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
897
+ try:
898
+ return await self._enforce_limits_for_request_once(
899
+ key_id,
900
+ request_model=request_model,
901
+ request_service_tier=request_service_tier,
902
+ request_usage_budget=request_usage_budget,
903
+ )
904
+ except OperationalError as exc:
905
+ await self._repository.rollback()
906
+ if not await should_retry_after_sqlite_lock(
907
+ exc,
908
+ what="enforce_limits_for_request",
909
+ attempt=attempt,
910
+ max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
911
+ base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
912
+ ):
913
+ raise
914
+
915
+ raise RuntimeError("unreachable")
916
+
917
+ async def _enforce_limits_for_request_once(
918
+ self,
919
+ key_id: str,
920
+ *,
921
+ request_model: str | None,
922
+ request_service_tier: str | None,
923
+ request_usage_budget: ApiKeyRequestUsageBudget | None,
924
+ ) -> ApiKeyUsageReservationData | None:
925
+ now = utcnow()
926
+ async with sqlite_writer_section():
927
+ row = _ensure_valid_api_key_row(await self._repository.get_for_limit_enforcement(key_id))
928
+ if row.expires_at is not None and row.expires_at < now:
929
+ raise ApiKeyInvalidError("API key has expired")
930
+ limits_reset = await _lazy_reset_expired_limits(self._repository, row.limits, now=now)
931
+ refreshed = (
932
+ _ensure_valid_api_key_row(await self._repository.get_for_limit_enforcement(key_id))
933
+ if limits_reset
934
+ else row
935
+ )
936
+ if refreshed.expires_at is not None and refreshed.expires_at < now:
937
+ raise ApiKeyInvalidError("API key has expired")
938
+
939
+ reservation_items: list[UsageReservationItemData] = []
940
+ reservation_id: str | None = None
941
+ normalized_usage_budget = _normalize_request_usage_budget(request_usage_budget)
942
+ try:
943
+ for limit in refreshed.limits:
944
+ if not _limit_applies_for_request(limit, request_model=request_model):
945
+ continue
946
+ if limit.current_value >= limit.max_value:
947
+ raise _rate_limit_exceeded_error(limit)
948
+ reserve_delta = _reserve_delta_for_limit(
949
+ limit,
950
+ request_model=request_model,
951
+ request_service_tier=request_service_tier,
952
+ request_usage_budget=normalized_usage_budget,
953
+ )
954
+ if reserve_delta > 0:
955
+ result = await self._repository.try_reserve_usage(
956
+ limit.id,
957
+ delta=reserve_delta,
958
+ expected_reset_at=limit.reset_at,
959
+ )
960
+ if not result.success:
961
+ raise _rate_limit_exceeded_error(limit)
962
+ reservation_items.append(
963
+ UsageReservationItemData(
964
+ limit_id=limit.id,
965
+ limit_type=limit.limit_type,
966
+ reserved_delta=reserve_delta,
967
+ expected_reset_at=limit.reset_at,
968
+ )
969
+ )
970
+
971
+ if not reservation_items:
972
+ # No configured limit applies to this request, so there is
973
+ # nothing to reserve and nothing to settle. Skip the empty
974
+ # reservation INSERT and its full-durability commit (the
975
+ # lazy expired-limit reset above commits inside
976
+ # ``reset_limit`` itself, so no write is pending here).
977
+ # Every downstream consumer treats a missing reservation
978
+ # as "nothing to settle". Commit to close the implicit
979
+ # transaction opened by the admission SELECTs: on sessions
980
+ # that outlive this call (e.g. the quota-planner warmup
981
+ # service) an open transaction would otherwise idle across
982
+ # the upstream round-trip until the next commit. This must
983
+ # be ``commit()`` rather than ``rollback()``:
984
+ # ``AsyncSession.rollback()`` expires every tracked ORM
985
+ # object regardless of ``expire_on_commit``, and the warmup
986
+ # service shares this session with already-loaded
987
+ # ``account``/``decision`` rows whose attribute access
988
+ # after expiry raises ``MissingGreenlet``. ``commit()``
989
+ # with ``expire_on_commit=False`` (app/db/session.py)
990
+ # leaves tracked state loaded, and is semantically
991
+ # equivalent here because the transaction holds only the
992
+ # admission SELECTs — no reservation write ran, and every
993
+ # production caller either dedicates a session to
994
+ # admission (proxy paths) or commits each prior write
995
+ # inside its repository methods (quota-planner), so no
996
+ # unrelated dirty state can be flushed by this commit.
997
+ await self._repository.commit()
998
+ else:
999
+ reservation_id = _next_usage_reservation_id()
1000
+ await self._repository.create_usage_reservation(
1001
+ reservation_id,
1002
+ key_id=key_id,
1003
+ model=request_model or "",
1004
+ items=reservation_items,
1005
+ )
1006
+ await self._repository.commit()
1007
+ except Exception:
1008
+ await self._repository.rollback()
1009
+ raise
1010
+
1011
+ if reservation_id is None:
1012
+ # Settlement is the only other production writer of
1013
+ # ``last_used_at``; without a reservation it never runs, so record
1014
+ # the last-used touch at admission instead. Recorded outside
1015
+ # sqlite_writer_section() for the same reason as settlement: the
1016
+ # shutdown write-through flush takes the writer section itself.
1017
+ await self._last_used_coalescer.record(key_id, utcnow())
1018
+ return None
1019
+
1020
+ return ApiKeyUsageReservationData(
1021
+ reservation_id=reservation_id,
1022
+ key_id=key_id,
1023
+ model=request_model or "",
1024
+ has_applicable_limits=bool(reservation_items),
1025
+ )
1026
+
1027
+ async def finalize_usage_reservation(
1028
+ self,
1029
+ reservation_id: str,
1030
+ *,
1031
+ model: str,
1032
+ input_tokens: int,
1033
+ output_tokens: int,
1034
+ cached_input_tokens: int = 0,
1035
+ service_tier: str | None = None,
1036
+ cost_microdollars: int | None = None,
1037
+ ) -> None:
1038
+ for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
1039
+ try:
1040
+ await self._settle_usage_reservation(
1041
+ reservation_id,
1042
+ model=model,
1043
+ input_tokens=input_tokens,
1044
+ output_tokens=output_tokens,
1045
+ cached_input_tokens=cached_input_tokens,
1046
+ service_tier=service_tier,
1047
+ status="finalized",
1048
+ cost_microdollars_override=cost_microdollars,
1049
+ )
1050
+ return
1051
+ except OperationalError as exc:
1052
+ await self._repository.rollback()
1053
+ if not await should_retry_after_sqlite_lock(
1054
+ exc,
1055
+ what="finalize_usage_reservation",
1056
+ attempt=attempt,
1057
+ max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
1058
+ base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
1059
+ ):
1060
+ raise
1061
+
1062
+ raise RuntimeError("unreachable")
1063
+
1064
+ async def fail_usage_reservation(
1065
+ self,
1066
+ reservation_id: str,
1067
+ *,
1068
+ model: str,
1069
+ input_tokens: int | None = None,
1070
+ output_tokens: int | None = None,
1071
+ cached_input_tokens: int | None = None,
1072
+ service_tier: str | None = None,
1073
+ ) -> None:
1074
+ for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
1075
+ try:
1076
+ await self._settle_usage_reservation(
1077
+ reservation_id,
1078
+ model=model,
1079
+ input_tokens=input_tokens,
1080
+ output_tokens=output_tokens,
1081
+ cached_input_tokens=cached_input_tokens,
1082
+ service_tier=service_tier,
1083
+ status="failed",
1084
+ )
1085
+ return
1086
+ except OperationalError as exc:
1087
+ await self._repository.rollback()
1088
+ if not await should_retry_after_sqlite_lock(
1089
+ exc,
1090
+ what="fail_usage_reservation",
1091
+ attempt=attempt,
1092
+ max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
1093
+ base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
1094
+ ):
1095
+ raise
1096
+
1097
+ raise RuntimeError("unreachable")
1098
+
1099
+ async def _settle_usage_reservation(
1100
+ self,
1101
+ reservation_id: str,
1102
+ *,
1103
+ model: str,
1104
+ input_tokens: int | None,
1105
+ output_tokens: int | None,
1106
+ cached_input_tokens: int | None,
1107
+ service_tier: str | None,
1108
+ status: str,
1109
+ cost_microdollars_override: int | None = None,
1110
+ ) -> None:
1111
+ async with sqlite_writer_section():
1112
+ reservation = await self._repository.get_usage_reservation(reservation_id)
1113
+ if reservation is None or reservation.status != "reserved":
1114
+ return
1115
+
1116
+ claimed = await self._repository.transition_usage_reservation_status(
1117
+ reservation_id,
1118
+ expected_status="reserved",
1119
+ new_status="settling",
1120
+ )
1121
+ if not claimed:
1122
+ await self._repository.rollback()
1123
+ return
1124
+
1125
+ effective_input_tokens = input_tokens or 0
1126
+ effective_output_tokens = output_tokens or 0
1127
+ effective_cached_input_tokens = cached_input_tokens or 0
1128
+ cost_microdollars = (
1129
+ cost_microdollars_override
1130
+ if cost_microdollars_override is not None
1131
+ else _calculate_cost_microdollars(
1132
+ model,
1133
+ effective_input_tokens,
1134
+ effective_output_tokens,
1135
+ effective_cached_input_tokens,
1136
+ service_tier,
1137
+ )
1138
+ )
1139
+
1140
+ try:
1141
+ for item in reservation.items:
1142
+ actual_delta = _compute_increment_for_limit_type(
1143
+ item.limit_type,
1144
+ input_tokens=effective_input_tokens,
1145
+ output_tokens=effective_output_tokens,
1146
+ cost_microdollars=cost_microdollars,
1147
+ )
1148
+ delta = actual_delta - item.reserved_delta
1149
+ if delta != 0:
1150
+ await self._repository.adjust_reserved_usage(
1151
+ item.limit_id,
1152
+ delta=delta,
1153
+ expected_reset_at=item.expected_reset_at,
1154
+ )
1155
+ await self._repository.upsert_reservation_item_actual(
1156
+ reservation_id,
1157
+ item=item,
1158
+ actual_delta=actual_delta,
1159
+ )
1160
+
1161
+ await self._repository.settle_usage_reservation(
1162
+ reservation_id,
1163
+ status=status,
1164
+ input_tokens=input_tokens,
1165
+ output_tokens=output_tokens,
1166
+ cached_input_tokens=cached_input_tokens,
1167
+ cost_microdollars=cost_microdollars,
1168
+ )
1169
+ await self._repository.commit()
1170
+ except Exception:
1171
+ await self._repository.rollback()
1172
+ raise
1173
+
1174
+ # Write-behind: the periodic flusher persists last_used_at (coalesced,
1175
+ # greatest-wins) instead of this settlement commit. Recorded outside
1176
+ # sqlite_writer_section(): during shutdown write-through the record
1177
+ # flushes immediately, and that flush takes the writer section itself.
1178
+ await self._last_used_coalescer.record(reservation.api_key_id, utcnow())
1179
+
1180
+ async def touch_usage_reservation(self, reservation_id: str) -> bool:
1181
+ for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
1182
+ try:
1183
+ async with sqlite_writer_section():
1184
+ touched = await self._repository.touch_usage_reservation(reservation_id)
1185
+ await self._repository.commit()
1186
+ return touched
1187
+ except OperationalError as exc:
1188
+ await self._repository.rollback()
1189
+ if not await should_retry_after_sqlite_lock(
1190
+ exc,
1191
+ what="touch_usage_reservation",
1192
+ attempt=attempt,
1193
+ max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
1194
+ base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
1195
+ ):
1196
+ raise
1197
+
1198
+ raise RuntimeError("unreachable")
1199
+
1200
+ async def release_usage_reservation(self, reservation_id: str) -> None:
1201
+ for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
1202
+ try:
1203
+ await self._release_usage_reservation_once(reservation_id)
1204
+ return
1205
+ except OperationalError as exc:
1206
+ await self._repository.rollback()
1207
+ if not await should_retry_after_sqlite_lock(
1208
+ exc,
1209
+ what="release_usage_reservation",
1210
+ attempt=attempt,
1211
+ max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
1212
+ base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
1213
+ ):
1214
+ raise
1215
+
1216
+ raise RuntimeError("unreachable")
1217
+
1218
+ async def _release_usage_reservation_once(self, reservation_id: str) -> None:
1219
+ async with sqlite_writer_section():
1220
+ reservation = await self._repository.get_usage_reservation(reservation_id)
1221
+ if reservation is None or reservation.status != "reserved":
1222
+ return
1223
+
1224
+ claimed = await self._repository.transition_usage_reservation_status(
1225
+ reservation_id,
1226
+ expected_status="reserved",
1227
+ new_status="released",
1228
+ )
1229
+ if not claimed:
1230
+ await self._repository.rollback()
1231
+ return
1232
+
1233
+ try:
1234
+ for item in reservation.items:
1235
+ await self._repository.adjust_reserved_usage(
1236
+ item.limit_id,
1237
+ delta=-item.reserved_delta,
1238
+ expected_reset_at=item.expected_reset_at,
1239
+ )
1240
+ await self._repository.upsert_reservation_item_actual(
1241
+ reservation_id,
1242
+ item=item,
1243
+ actual_delta=0,
1244
+ )
1245
+ await self._repository.settle_usage_reservation(
1246
+ reservation_id,
1247
+ status="released",
1248
+ input_tokens=None,
1249
+ output_tokens=None,
1250
+ cached_input_tokens=None,
1251
+ cost_microdollars=None,
1252
+ )
1253
+ await self._repository.commit()
1254
+ except Exception:
1255
+ await self._repository.rollback()
1256
+ raise
1257
+
1258
+ async def record_usage(
1259
+ self,
1260
+ key_id: str,
1261
+ *,
1262
+ model: str,
1263
+ input_tokens: int,
1264
+ output_tokens: int,
1265
+ cached_input_tokens: int = 0,
1266
+ service_tier: str | None = None,
1267
+ ) -> None:
1268
+ cost_microdollars = _calculate_cost_microdollars(
1269
+ model,
1270
+ input_tokens,
1271
+ output_tokens,
1272
+ cached_input_tokens,
1273
+ service_tier,
1274
+ )
1275
+ await self._repository.increment_limit_usage(
1276
+ key_id,
1277
+ model=model,
1278
+ input_tokens=input_tokens,
1279
+ output_tokens=output_tokens,
1280
+ cost_microdollars=cost_microdollars,
1281
+ )
1282
+ await self._last_used_coalescer.record(key_id, utcnow())
1283
+
1284
+ async def get_key_trends(self, key_id: str) -> ApiKeyTrendsData | None:
1285
+ row = await self._repository.get_by_id(key_id)
1286
+ if row is None:
1287
+ return None
1288
+ now = utcnow()
1289
+ since = now - timedelta(days=_SPARKLINE_DAYS)
1290
+ buckets = await self._repository.trends_by_key(
1291
+ key_id,
1292
+ since,
1293
+ now,
1294
+ _DETAIL_BUCKET_SECONDS,
1295
+ )
1296
+ return _build_api_key_trends(key_id, buckets, since, now, _DETAIL_BUCKET_SECONDS)
1297
+
1298
+ async def get_key_usage_summary_for_self(self, key_id: str) -> ApiKeySelfUsageData | None:
1299
+ """Return usage summary + current limits for a single key (self-service lookup)."""
1300
+ row = await self._repository.get_by_id(key_id)
1301
+ if row is None:
1302
+ return None
1303
+
1304
+ now = utcnow()
1305
+ # Reset any expired limits before reading state
1306
+ limits_reset = await _lazy_reset_expired_limits(self._repository, row.limits, now=now)
1307
+ refreshed = await self._repository.get_by_id(key_id) if limits_reset else row
1308
+ if refreshed is None:
1309
+ return None
1310
+
1311
+ usage = await self._repository.get_usage_summary_by_key_id(key_id)
1312
+ limits = [
1313
+ ApiKeySelfLimitData(
1314
+ limit_type=limit.limit_type.value,
1315
+ limit_window=limit.limit_window.value,
1316
+ max_value=limit.max_value,
1317
+ current_value=max(0, min(limit.current_value, limit.max_value)),
1318
+ remaining_value=max(0, limit.max_value - max(0, min(limit.current_value, limit.max_value))),
1319
+ model_filter=limit.model_filter,
1320
+ reset_at=limit.reset_at,
1321
+ source="api_key_limit",
1322
+ )
1323
+ for limit in refreshed.limits
1324
+ ]
1325
+ return ApiKeySelfUsageData(
1326
+ request_count=usage.request_count,
1327
+ total_tokens=usage.total_tokens,
1328
+ cached_input_tokens=usage.cached_input_tokens,
1329
+ total_cost_usd=usage.total_cost_usd,
1330
+ limits=limits,
1331
+ )
1332
+
1333
+ async def get_key_usage_7d(self, key_id: str) -> ApiKeyUsage7DayData | None:
1334
+ row = await self._repository.get_by_id(key_id)
1335
+ if row is None:
1336
+ return None
1337
+ now = utcnow()
1338
+ since = now - timedelta(days=7)
1339
+ data = await self._repository.usage_7d(key_id, since, now)
1340
+ return ApiKeyUsage7DayData(
1341
+ key_id=key_id,
1342
+ total_tokens=data.total_tokens,
1343
+ total_cost_usd=data.total_cost_usd,
1344
+ total_requests=data.total_requests,
1345
+ cached_input_tokens=data.cached_input_tokens,
1346
+ account_costs=[
1347
+ ApiKeyAccountCostData(
1348
+ account_id=ac.account_id,
1349
+ email=ac.email,
1350
+ cost_usd=ac.cost_usd,
1351
+ is_deleted=ac.is_deleted,
1352
+ )
1353
+ for ac in data.account_costs
1354
+ ],
1355
+ )
1356
+
1357
+
1358
+ @dataclass(frozen=True, slots=True)
1359
+ class ApiKeyTrendsPoint:
1360
+ t: datetime
1361
+ v: float
1362
+
1363
+
1364
+ @dataclass(frozen=True, slots=True)
1365
+ class ApiKeyTrendsData:
1366
+ key_id: str
1367
+ cost: list[ApiKeyTrendsPoint] = field(default_factory=list)
1368
+ tokens: list[ApiKeyTrendsPoint] = field(default_factory=list)
1369
+
1370
+
1371
+ @dataclass(frozen=True, slots=True)
1372
+ class ApiKeyAccountCostData:
1373
+ account_id: str | None
1374
+ email: str | None
1375
+ cost_usd: float
1376
+ is_deleted: bool = False
1377
+
1378
+
1379
+ @dataclass(frozen=True, slots=True)
1380
+ class ApiKeyUsage7DayData:
1381
+ key_id: str
1382
+ total_tokens: int = 0
1383
+ total_cost_usd: float = 0.0
1384
+ total_requests: int = 0
1385
+ cached_input_tokens: int = 0
1386
+ account_costs: list[ApiKeyAccountCostData] = field(default_factory=list)
1387
+
1388
+
1389
+ @dataclass(frozen=True, slots=True)
1390
+ class ApiKeySelfLimitData:
1391
+ limit_type: str
1392
+ limit_window: str
1393
+ max_value: int
1394
+ current_value: int
1395
+ remaining_value: int
1396
+ model_filter: str | None
1397
+ reset_at: datetime
1398
+ source: str = "api_key_limit"
1399
+
1400
+
1401
+ @dataclass(frozen=True, slots=True)
1402
+ class ApiKeySelfUsageData:
1403
+ request_count: int = 0
1404
+ total_tokens: int = 0
1405
+ cached_input_tokens: int = 0
1406
+ total_cost_usd: float = 0.0
1407
+ limits: list[ApiKeySelfLimitData] = field(default_factory=list)
1408
+
1409
+
1410
+ def _normalize_name(name: str) -> str:
1411
+ normalized = name.strip()
1412
+ if not normalized:
1413
+ raise ApiKeyValidationError("API key name is required")
1414
+ return normalized
1415
+
1416
+
1417
+ _VALID_USAGE_SECTIONS = {"upstream_limits", "account_pool_usage"}
1418
+ _DEFAULT_USAGE_SECTIONS = "upstream_limits,account_pool_usage"
1419
+
1420
+
1421
+ def _normalize_usage_sections(raw: str | None) -> str:
1422
+ if raw is None:
1423
+ return _DEFAULT_USAGE_SECTIONS
1424
+ if not raw.strip():
1425
+ return ""
1426
+ sections = [s.strip() for s in raw.split(",") if s.strip()]
1427
+ invalid = [s for s in sections if s not in _VALID_USAGE_SECTIONS]
1428
+ if invalid:
1429
+ raise ApiKeyValidationError(f"Invalid usage sections: {', '.join(invalid)}")
1430
+ return ",".join(dict.fromkeys(sections))
1431
+
1432
+
1433
+ def _get_usage_sections_with_default(row: ApiKey) -> str:
1434
+ value = getattr(row, "usage_sections", None)
1435
+ if value is None:
1436
+ return _DEFAULT_USAGE_SECTIONS
1437
+ return value
1438
+
1439
+
1440
+ def _generate_plain_key() -> str:
1441
+ return f"sk-clb-{secrets.token_hex(24)}"
1442
+
1443
+
1444
+ def _hash_key(plain_key: str) -> str:
1445
+ return sha256(plain_key.encode("utf-8")).hexdigest()
1446
+
1447
+
1448
+ def _serialize_allowed_models(allowed_models: list[str] | None) -> str | None:
1449
+ if allowed_models is None:
1450
+ return None
1451
+ return json.dumps(allowed_models)
1452
+
1453
+
1454
+ def _serialize_allowed_reasoning_efforts(allowed_reasoning_efforts: list[str] | None) -> str | None:
1455
+ if allowed_reasoning_efforts is None:
1456
+ return None
1457
+ return json.dumps(allowed_reasoning_efforts)
1458
+
1459
+
1460
+ def _deserialize_allowed_models(payload: str | None) -> list[str] | None:
1461
+ if payload is None:
1462
+ return None
1463
+ parsed = json.loads(payload)
1464
+ if not isinstance(parsed, list):
1465
+ return None
1466
+ models = [value.strip() for value in parsed if isinstance(value, str) and value.strip()]
1467
+ return models
1468
+
1469
+
1470
+ def _deserialize_allowed_reasoning_efforts(payload: str | None) -> list[str] | None:
1471
+ if payload is None:
1472
+ return None
1473
+ try:
1474
+ parsed = json.loads(payload)
1475
+ if not isinstance(parsed, list):
1476
+ return []
1477
+ return _normalize_allowed_reasoning_efforts(parsed)
1478
+ except (ApiKeyValidationError, TypeError, json.JSONDecodeError):
1479
+ return []
1480
+
1481
+
1482
+ def _is_reasoning_policy_constraint_error(exc: IntegrityError) -> bool:
1483
+ return _REASONING_POLICY_EXCLUSIVE_CONSTRAINT in str(exc).lower()
1484
+
1485
+
1486
+ def _normalize_allowed_models(allowed_models: list[str] | None) -> list[str] | None:
1487
+ if allowed_models is None:
1488
+ return None
1489
+ return [model.strip() for model in allowed_models if model and model.strip()]
1490
+
1491
+
1492
+ def _normalize_assigned_account_ids(account_ids: list[str] | None) -> list[str]:
1493
+ if not account_ids:
1494
+ return []
1495
+ normalized: list[str] = []
1496
+ seen: set[str] = set()
1497
+ for account_id in account_ids:
1498
+ value = account_id.strip()
1499
+ if not value or value in seen:
1500
+ continue
1501
+ normalized.append(value)
1502
+ seen.add(value)
1503
+ return normalized
1504
+
1505
+
1506
+ def _normalize_assigned_source_ids(source_ids: list[str] | None) -> list[str]:
1507
+ if not source_ids:
1508
+ return []
1509
+ normalized: list[str] = []
1510
+ seen: set[str] = set()
1511
+ for source_id in source_ids:
1512
+ value = source_id.strip()
1513
+ if not value or value in seen:
1514
+ continue
1515
+ normalized.append(value)
1516
+ seen.add(value)
1517
+ return normalized
1518
+
1519
+
1520
+ def _normalize_model_slug(value: str | None) -> str | None:
1521
+ if value is None:
1522
+ return None
1523
+ normalized = value.strip()
1524
+ if not normalized:
1525
+ return None
1526
+ return normalized
1527
+
1528
+
1529
+ _REASONING_EFFORT_ORDER = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
1530
+ _SUPPORTED_REASONING_EFFORTS = frozenset({"none", *_REASONING_EFFORT_ORDER})
1531
+ _SUPPORTED_SELECTABLE_REASONING_EFFORTS = frozenset(_REASONING_EFFORT_ORDER)
1532
+ _SUPPORTED_SERVICE_TIERS = frozenset({"auto", "default", "priority", "flex", "ultrafast"})
1533
+
1534
+
1535
+ def _normalize_expires_at(value: datetime | None) -> datetime | None:
1536
+ if value is None:
1537
+ return None
1538
+ return to_utc_naive(value)
1539
+
1540
+
1541
+ def _normalize_reasoning_effort(value: str | None) -> str | None:
1542
+ if value is None:
1543
+ return None
1544
+ normalized = value.strip().lower()
1545
+ if not normalized:
1546
+ return None
1547
+ if normalized not in _SUPPORTED_REASONING_EFFORTS:
1548
+ options = ", ".join(sorted(_SUPPORTED_REASONING_EFFORTS))
1549
+ raise ApiKeyValidationError(f"Unsupported enforced reasoning effort '{normalized}'. Expected one of: {options}")
1550
+ return normalized
1551
+
1552
+
1553
+ def _normalize_reasoning_effort_lenient(value: str | None) -> str | None:
1554
+ if value is None:
1555
+ return None
1556
+ normalized = value.strip().lower()
1557
+ if not normalized:
1558
+ return None
1559
+ if normalized in _SUPPORTED_REASONING_EFFORTS:
1560
+ return normalized
1561
+ return None
1562
+
1563
+
1564
+ def _normalize_allowed_reasoning_efforts(values: list[str] | None) -> list[str] | None:
1565
+ if values is None:
1566
+ return None
1567
+
1568
+ normalized: set[str] = set()
1569
+ for value in values:
1570
+ if not isinstance(value, str):
1571
+ raise ApiKeyValidationError("Allowed reasoning efforts must be strings")
1572
+ effort = value.strip().lower()
1573
+ if effort not in _SUPPORTED_SELECTABLE_REASONING_EFFORTS:
1574
+ options = ", ".join(_REASONING_EFFORT_ORDER)
1575
+ raise ApiKeyValidationError(f"Unsupported allowed reasoning effort '{effort}'. Expected one of: {options}")
1576
+ normalized.add(effort)
1577
+
1578
+ if not normalized:
1579
+ raise ApiKeyValidationError("Allowed reasoning efforts must not be empty")
1580
+ return [effort for effort in _REASONING_EFFORT_ORDER if effort in normalized]
1581
+
1582
+
1583
+ def _normalize_service_tier(value: str | None) -> str | None:
1584
+ if value is None:
1585
+ return None
1586
+ normalized = value.strip().lower()
1587
+ if not normalized:
1588
+ return None
1589
+ if normalized == "fast":
1590
+ normalized = "priority"
1591
+ if normalized not in _SUPPORTED_SERVICE_TIERS:
1592
+ options = ", ".join(sorted(_SUPPORTED_SERVICE_TIERS | {"fast"}))
1593
+ raise ApiKeyValidationError(f"Unsupported enforced service tier '{normalized}'. Expected one of: {options}")
1594
+ return normalized
1595
+
1596
+
1597
+ def _normalize_service_tier_lenient(value: str | None) -> str | None:
1598
+ if value is None:
1599
+ return None
1600
+ normalized = value.strip().lower()
1601
+ if not normalized:
1602
+ return None
1603
+ if normalized == "fast":
1604
+ return "priority"
1605
+ if normalized in _SUPPORTED_SERVICE_TIERS:
1606
+ return normalized
1607
+ return None
1608
+
1609
+
1610
+ def _normalize_traffic_class(value: str | None) -> str:
1611
+ normalized = (value or TRAFFIC_CLASS_FOREGROUND).strip().lower()
1612
+ if normalized not in _SUPPORTED_TRAFFIC_CLASSES:
1613
+ options = ", ".join(sorted(_SUPPORTED_TRAFFIC_CLASSES))
1614
+ raise ValueError(f"Unsupported traffic class '{normalized}'. Expected one of: {options}")
1615
+ return normalized
1616
+
1617
+
1618
+ def _normalize_traffic_class_lenient(value: str | None) -> str:
1619
+ normalized = (value or TRAFFIC_CLASS_FOREGROUND).strip().lower()
1620
+ if normalized in _SUPPORTED_TRAFFIC_CLASSES:
1621
+ return normalized
1622
+ return TRAFFIC_CLASS_FOREGROUND
1623
+
1624
+
1625
+ def _normalize_thread_cache_identity_override(value: str | None) -> str | None:
1626
+ """Strict (write path): an unknown value is a 400, not a silent fallback."""
1627
+ if value is None:
1628
+ return None
1629
+ normalized = normalize_thread_cache_identity_mode(value)
1630
+ if normalized is not None:
1631
+ return normalized
1632
+ options = ", ".join(sorted(THREAD_CACHE_IDENTITY_MODES))
1633
+ raise ApiKeyValidationError(
1634
+ f"Unsupported thread cache identity override '{value.strip().lower()}'. Expected one of: {options}"
1635
+ )
1636
+
1637
+
1638
+ def _normalize_thread_cache_identity_override_lenient(value: str | None) -> str | None:
1639
+ """Read path: a stale or hand-edited value reads as "no override" rather than a 500."""
1640
+ return normalize_thread_cache_identity_mode(value)
1641
+
1642
+
1643
+ def _normalize_transport_policy_override(value: str | None) -> str | None:
1644
+ if value is None:
1645
+ return None
1646
+ normalized = value.strip().lower()
1647
+ if normalized in _SUPPORTED_TRANSPORT_POLICY_OVERRIDES:
1648
+ return normalized
1649
+ options = ", ".join(sorted(_SUPPORTED_TRANSPORT_POLICY_OVERRIDES))
1650
+ raise ApiKeyValidationError(f"Unsupported transport policy override '{normalized}'. Expected one of: {options}")
1651
+
1652
+
1653
+ def _normalize_transport_policy_override_lenient(value: str | None) -> str | None:
1654
+ if value is None:
1655
+ return None
1656
+ normalized = value.strip().lower()
1657
+ if normalized in _SUPPORTED_TRANSPORT_POLICY_OVERRIDES:
1658
+ return normalized
1659
+ return None
1660
+
1661
+
1662
+ def _validate_model_enforcement(*, enforced_model: str | None, allowed_models: list[str] | None) -> None:
1663
+ if enforced_model is None or not allowed_models:
1664
+ return
1665
+ if enforced_model not in allowed_models:
1666
+ raise ApiKeyValidationError(
1667
+ "enforced_model must be present in allowed_models when allowed_models is configured"
1668
+ )
1669
+
1670
+
1671
+ def _validate_reasoning_effort_policy(
1672
+ *,
1673
+ enforced_reasoning_effort: str | None,
1674
+ allowed_reasoning_efforts: list[str] | None,
1675
+ ) -> None:
1676
+ if enforced_reasoning_effort is not None and allowed_reasoning_efforts is not None:
1677
+ raise ApiKeyValidationError(
1678
+ "enforced_reasoning_effort and allowed_reasoning_efforts cannot be configured together"
1679
+ )
1680
+
1681
+
1682
+ def _to_limit_rule_data(limit: ApiKeyLimit) -> LimitRuleData:
1683
+ return LimitRuleData(
1684
+ id=limit.id,
1685
+ limit_type=limit.limit_type.value,
1686
+ limit_window=limit.limit_window.value,
1687
+ max_value=limit.max_value,
1688
+ current_value=limit.current_value,
1689
+ model_filter=limit.model_filter,
1690
+ reset_at=limit.reset_at,
1691
+ )
1692
+
1693
+
1694
+ def _ensure_valid_api_key_row(row: ApiKey | None) -> ApiKey:
1695
+ if row is None or not row.is_active:
1696
+ raise ApiKeyInvalidError("Invalid API key")
1697
+ return row
1698
+
1699
+
1700
+ async def _lazy_reset_expired_limits(
1701
+ repository: ApiKeysRepositoryProtocol,
1702
+ limits: list[ApiKeyLimit],
1703
+ *,
1704
+ now: datetime,
1705
+ ) -> bool:
1706
+ reset_performed = False
1707
+ for limit in limits:
1708
+ if limit.reset_at >= now:
1709
+ continue
1710
+ new_reset_at = advance_limit_reset(limit.reset_at, now, limit.limit_window)
1711
+ await repository.reset_limit(
1712
+ limit.id,
1713
+ expected_reset_at=limit.reset_at,
1714
+ new_reset_at=new_reset_at,
1715
+ )
1716
+ reset_performed = True
1717
+ return reset_performed
1718
+
1719
+
1720
+ def _rate_limit_exceeded_error(limit: ApiKeyLimit) -> ApiKeyRateLimitExceededError:
1721
+ return ApiKeyRateLimitExceededError(
1722
+ message=f"API key {limit.limit_type.value} {limit.limit_window.value} limit exceeded"
1723
+ + (f" for model {limit.model_filter}" if limit.model_filter else ""),
1724
+ reset_at=limit.reset_at,
1725
+ )
1726
+
1727
+
1728
+ def _limit_applies_for_request(limit: ApiKeyLimit, *, request_model: str | None) -> bool:
1729
+ if limit.model_filter is None:
1730
+ return True
1731
+ if request_model is None:
1732
+ return False
1733
+ return limit.model_filter == request_model
1734
+
1735
+
1736
+ def _reserve_delta_for_limit(
1737
+ limit: ApiKeyLimit,
1738
+ *,
1739
+ request_model: str | None,
1740
+ request_service_tier: str | None,
1741
+ request_usage_budget: ApiKeyRequestUsageBudget,
1742
+ ) -> int:
1743
+ remaining = limit.max_value - limit.current_value
1744
+ if remaining <= 0:
1745
+ return 0
1746
+ budget = _reserve_budget_for_limit_type(
1747
+ limit.limit_type,
1748
+ request_model=request_model,
1749
+ request_service_tier=request_service_tier,
1750
+ request_usage_budget=request_usage_budget,
1751
+ )
1752
+ return min(remaining, budget)
1753
+
1754
+
1755
+ def _normalize_request_usage_budget(budget: ApiKeyRequestUsageBudget | None) -> ApiKeyRequestUsageBudget:
1756
+ if budget is None:
1757
+ return ApiKeyRequestUsageBudget(
1758
+ input_tokens=API_KEY_USAGE_RESERVATION_DEFAULT_INPUT_TOKENS,
1759
+ output_tokens=API_KEY_USAGE_RESERVATION_DEFAULT_OUTPUT_TOKENS,
1760
+ )
1761
+ return ApiKeyRequestUsageBudget(
1762
+ input_tokens=_normalize_reservation_token_budget(
1763
+ budget.input_tokens,
1764
+ default=API_KEY_USAGE_RESERVATION_DEFAULT_INPUT_TOKENS,
1765
+ ),
1766
+ output_tokens=_normalize_reservation_token_budget(
1767
+ budget.output_tokens,
1768
+ default=API_KEY_USAGE_RESERVATION_DEFAULT_OUTPUT_TOKENS,
1769
+ ),
1770
+ )
1771
+
1772
+
1773
+ def _normalize_reservation_token_budget(value: int | None, *, default: int) -> int:
1774
+ if value is None:
1775
+ return default
1776
+ return max(0, min(value, API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET))
1777
+
1778
+
1779
+ def _reserve_budget_for_limit_type(
1780
+ limit_type: LimitType,
1781
+ *,
1782
+ request_model: str | None,
1783
+ request_service_tier: str | None,
1784
+ request_usage_budget: ApiKeyRequestUsageBudget,
1785
+ ) -> int:
1786
+ input_tokens = request_usage_budget.input_tokens or 0
1787
+ output_tokens = request_usage_budget.output_tokens or 0
1788
+ if limit_type == LimitType.TOTAL_TOKENS:
1789
+ return input_tokens + output_tokens
1790
+ if limit_type == LimitType.INPUT_TOKENS:
1791
+ return input_tokens
1792
+ if limit_type == LimitType.OUTPUT_TOKENS:
1793
+ return output_tokens
1794
+ if limit_type == LimitType.COST_USD:
1795
+ return _reserve_cost_budget_microdollars(
1796
+ request_model,
1797
+ request_service_tier,
1798
+ input_tokens=input_tokens,
1799
+ output_tokens=output_tokens,
1800
+ )
1801
+ if limit_type == LimitType.CREDITS:
1802
+ return 0
1803
+ return 1
1804
+
1805
+
1806
+ def _reserve_cost_budget_microdollars(
1807
+ model: str | None,
1808
+ service_tier: str | None,
1809
+ *,
1810
+ input_tokens: int,
1811
+ output_tokens: int,
1812
+ ) -> int:
1813
+ if not model:
1814
+ return _unknown_model_reserve_cost_budget_microdollars(input_tokens=input_tokens, output_tokens=output_tokens)
1815
+ cost_microdollars = _calculate_cost_microdollars(
1816
+ model,
1817
+ input_tokens,
1818
+ output_tokens,
1819
+ 0,
1820
+ service_tier,
1821
+ )
1822
+ return (
1823
+ cost_microdollars
1824
+ if cost_microdollars > 0
1825
+ else _unknown_model_reserve_cost_budget_microdollars(input_tokens=input_tokens, output_tokens=output_tokens)
1826
+ )
1827
+
1828
+
1829
+ def _unknown_model_reserve_cost_budget_microdollars(*, input_tokens: int, output_tokens: int) -> int:
1830
+ token_budget = max(0, input_tokens) + max(0, output_tokens)
1831
+ if token_budget <= 0:
1832
+ return 0
1833
+ return ceil(
1834
+ _API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_MICRODOLLARS
1835
+ * token_budget
1836
+ / _API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_BASE_TOKENS
1837
+ )
1838
+
1839
+
1840
+ def _compute_increment_for_limit_type(
1841
+ limit_type: LimitType,
1842
+ *,
1843
+ input_tokens: int,
1844
+ output_tokens: int,
1845
+ cost_microdollars: int,
1846
+ ) -> int:
1847
+ if limit_type == LimitType.TOTAL_TOKENS:
1848
+ return input_tokens + output_tokens
1849
+ if limit_type == LimitType.INPUT_TOKENS:
1850
+ return input_tokens
1851
+ if limit_type == LimitType.OUTPUT_TOKENS:
1852
+ return output_tokens
1853
+ if limit_type == LimitType.COST_USD:
1854
+ return cost_microdollars
1855
+ if limit_type == LimitType.CREDITS:
1856
+ return 0
1857
+ return 0
1858
+
1859
+
1860
+ def _next_usage_reservation_id() -> str:
1861
+ return f"ur_{uuid.uuid4().hex}"
1862
+
1863
+
1864
+ def _to_created_data(data: ApiKeyData, key: str) -> ApiKeyCreatedData:
1865
+ return ApiKeyCreatedData(
1866
+ id=data.id,
1867
+ name=data.name,
1868
+ key_prefix=data.key_prefix,
1869
+ allowed_models=data.allowed_models,
1870
+ apply_to_codex_model=data.apply_to_codex_model,
1871
+ enforced_model=data.enforced_model,
1872
+ enforced_reasoning_effort=data.enforced_reasoning_effort,
1873
+ allowed_reasoning_efforts=data.allowed_reasoning_efforts,
1874
+ enforced_service_tier=data.enforced_service_tier,
1875
+ traffic_class=data.traffic_class,
1876
+ transport_policy_override=data.transport_policy_override,
1877
+ thread_cache_identity_override=data.thread_cache_identity_override,
1878
+ usage_sections=data.usage_sections,
1879
+ expires_at=data.expires_at,
1880
+ is_active=data.is_active,
1881
+ created_at=data.created_at,
1882
+ last_used_at=data.last_used_at,
1883
+ limits=data.limits,
1884
+ usage_summary=data.usage_summary,
1885
+ account_assignment_scope_enabled=data.account_assignment_scope_enabled,
1886
+ source_assignment_scope_enabled=data.source_assignment_scope_enabled,
1887
+ assigned_account_ids=data.assigned_account_ids,
1888
+ assigned_source_ids=data.assigned_source_ids,
1889
+ key=key,
1890
+ )
1891
+
1892
+
1893
+ def _to_api_key_data(
1894
+ row: ApiKey,
1895
+ *,
1896
+ usage_summary: ApiKeyUsageSummaryData | None = None,
1897
+ pooled_credits: PooledCreditData | None = None,
1898
+ ) -> ApiKeyData:
1899
+ limits = [_to_limit_rule_data(limit) for limit in row.limits] if row.limits else []
1900
+ account_assignments = getattr(row, "account_assignments", [])
1901
+ source_assignments = getattr(row, "source_assignments", [])
1902
+ return ApiKeyData(
1903
+ id=row.id,
1904
+ name=row.name,
1905
+ key_prefix=row.key_prefix,
1906
+ allowed_models=_deserialize_allowed_models(row.allowed_models),
1907
+ apply_to_codex_model=getattr(row, "apply_to_codex_model", False),
1908
+ enforced_model=_normalize_model_slug(row.enforced_model),
1909
+ enforced_reasoning_effort=_normalize_reasoning_effort_lenient(row.enforced_reasoning_effort),
1910
+ allowed_reasoning_efforts=_deserialize_allowed_reasoning_efforts(
1911
+ getattr(row, "allowed_reasoning_efforts", None)
1912
+ ),
1913
+ enforced_service_tier=_normalize_service_tier_lenient(row.enforced_service_tier),
1914
+ traffic_class=_normalize_traffic_class_lenient(getattr(row, "traffic_class", TRAFFIC_CLASS_FOREGROUND)),
1915
+ transport_policy_override=_normalize_transport_policy_override_lenient(
1916
+ getattr(row, "transport_policy_override", None)
1917
+ ),
1918
+ thread_cache_identity_override=_normalize_thread_cache_identity_override_lenient(
1919
+ getattr(row, "thread_cache_identity_override", None)
1920
+ ),
1921
+ usage_sections=_get_usage_sections_with_default(row),
1922
+ expires_at=row.expires_at,
1923
+ is_active=row.is_active,
1924
+ created_at=row.created_at,
1925
+ last_used_at=row.last_used_at,
1926
+ limits=limits,
1927
+ usage_summary=usage_summary,
1928
+ account_assignment_scope_enabled=getattr(row, "account_assignment_scope_enabled", False),
1929
+ source_assignment_scope_enabled=getattr(row, "source_assignment_scope_enabled", False),
1930
+ assigned_account_ids=[assignment.account_id for assignment in account_assignments],
1931
+ assigned_source_ids=[assignment.source_id for assignment in source_assignments],
1932
+ pooled_credits=pooled_credits,
1933
+ )
1934
+
1935
+
1936
+ def _to_usage_summary_data(summary: ApiKeyUsageSummary | None) -> ApiKeyUsageSummaryData | None:
1937
+ if summary is None:
1938
+ return None
1939
+ return ApiKeyUsageSummaryData(
1940
+ request_count=summary.request_count,
1941
+ total_tokens=summary.total_tokens,
1942
+ cached_input_tokens=summary.cached_input_tokens,
1943
+ total_cost_usd=summary.total_cost_usd,
1944
+ )
1945
+
1946
+
1947
+ def _limit_input_to_row(
1948
+ li: LimitRuleInput,
1949
+ key_id: str,
1950
+ now: datetime,
1951
+ *,
1952
+ current_value: int = 0,
1953
+ reset_at: datetime | None = None,
1954
+ ) -> ApiKeyLimit:
1955
+ window = LimitWindow(li.limit_window)
1956
+ if li.limit_type == LimitType.CREDITS.value and li.model_filter is not None:
1957
+ raise ApiKeyValidationError("credits limits do not support model_filter")
1958
+ return ApiKeyLimit(
1959
+ api_key_id=key_id,
1960
+ limit_type=LimitType(li.limit_type),
1961
+ limit_window=window,
1962
+ max_value=li.max_value,
1963
+ current_value=current_value,
1964
+ model_filter=li.model_filter,
1965
+ reset_at=reset_at if reset_at is not None else next_limit_reset(now, window),
1966
+ )
1967
+
1968
+
1969
+ async def _build_limit_rows_for_update(
1970
+ *,
1971
+ key_id: str,
1972
+ now: datetime,
1973
+ submitted_limits: list[LimitRuleInput],
1974
+ existing_limits: list[ApiKeyLimit],
1975
+ reset_usage: bool,
1976
+ repository: ApiKeysRepositoryProtocol | None = None,
1977
+ ) -> list[ApiKeyLimit]:
1978
+ existing_by_key = {_limit_identity_from_row(limit): limit for limit in existing_limits}
1979
+ _validate_unique_limit_rule_identities(submitted_limits)
1980
+
1981
+ rows: list[ApiKeyLimit] = []
1982
+ for submitted in submitted_limits:
1983
+ identity = _limit_identity_from_input(submitted)
1984
+ matched = existing_by_key.get(identity)
1985
+ if reset_usage:
1986
+ rows.append(_limit_input_to_row(submitted, key_id, now))
1987
+ continue
1988
+ if matched is None:
1989
+ if repository is None:
1990
+ raise TypeError("repository is required to backfill new API key limit usage")
1991
+ rows.append(await _new_limit_input_to_backfilled_row(submitted, key_id, now, repository))
1992
+ continue
1993
+ rows.append(
1994
+ _limit_input_to_row(
1995
+ submitted,
1996
+ key_id,
1997
+ now,
1998
+ current_value=matched.current_value,
1999
+ reset_at=matched.reset_at,
2000
+ )
2001
+ )
2002
+ return rows
2003
+
2004
+
2005
+ async def _new_limit_input_to_backfilled_row(
2006
+ submitted: LimitRuleInput,
2007
+ key_id: str,
2008
+ now: datetime,
2009
+ repository: ApiKeysRepositoryProtocol,
2010
+ ) -> ApiKeyLimit:
2011
+ limit_type = LimitType(submitted.limit_type)
2012
+ window = LimitWindow(submitted.limit_window)
2013
+ reset_at = next_limit_reset(now, window)
2014
+ since = now - limit_window_delta(window)
2015
+ current_value = await repository.get_limit_usage_value(
2016
+ key_id,
2017
+ limit_type=limit_type,
2018
+ since=since,
2019
+ until=now,
2020
+ model_filter=submitted.model_filter,
2021
+ )
2022
+ return _limit_input_to_row(
2023
+ submitted,
2024
+ key_id,
2025
+ now,
2026
+ current_value=current_value,
2027
+ reset_at=reset_at,
2028
+ )
2029
+
2030
+
2031
+ def _build_reset_limit_rows(
2032
+ *,
2033
+ key_id: str,
2034
+ now: datetime,
2035
+ existing_limits: list[ApiKeyLimit],
2036
+ ) -> list[ApiKeyLimit]:
2037
+ rows: list[ApiKeyLimit] = []
2038
+ for existing in existing_limits:
2039
+ rows.append(
2040
+ ApiKeyLimit(
2041
+ api_key_id=key_id,
2042
+ limit_type=existing.limit_type,
2043
+ limit_window=existing.limit_window,
2044
+ max_value=existing.max_value,
2045
+ current_value=0,
2046
+ model_filter=existing.model_filter,
2047
+ reset_at=next_limit_reset(now, existing.limit_window),
2048
+ )
2049
+ )
2050
+ return rows
2051
+
2052
+
2053
+ def _limit_identity_from_input(limit: LimitRuleInput) -> tuple[str, str, str | None]:
2054
+ return (limit.limit_type, limit.limit_window, limit.model_filter)
2055
+
2056
+
2057
+ def _validate_unique_limit_rule_identities(limits: list[LimitRuleInput]) -> None:
2058
+ identities: set[tuple[str, str, str | None]] = set()
2059
+ for limit in limits:
2060
+ identity = _limit_identity_from_input(limit)
2061
+ if identity in identities:
2062
+ raise ApiKeyValidationError(f"Duplicate limit rules are not allowed: {identity!r}")
2063
+ identities.add(identity)
2064
+
2065
+
2066
+ def _limit_identity_from_row(limit: ApiKeyLimit) -> tuple[str, str, str | None]:
2067
+ return (limit.limit_type.value, limit.limit_window.value, limit.model_filter)
2068
+
2069
+
2070
+ def _calculate_cost_microdollars(
2071
+ model: str,
2072
+ input_tokens: int,
2073
+ output_tokens: int,
2074
+ cached_input_tokens: int,
2075
+ service_tier: str | None = None,
2076
+ ) -> int:
2077
+ resolved = get_pricing_for_model(model)
2078
+ if resolved is None:
2079
+ return 0
2080
+ _, price = resolved
2081
+ usage = UsageTokens(
2082
+ input_tokens=float(input_tokens),
2083
+ output_tokens=float(output_tokens),
2084
+ cached_input_tokens=float(cached_input_tokens),
2085
+ )
2086
+ cost_usd = calculate_cost_from_usage(usage, price, service_tier=service_tier)
2087
+ if cost_usd is None:
2088
+ return 0
2089
+ return int(cost_usd * 1_000_000)
2090
+
2091
+
2092
+ def _build_api_key_trends(
2093
+ key_id: str,
2094
+ buckets: list[ApiKeyTrendBucket],
2095
+ since: datetime,
2096
+ until: datetime,
2097
+ bucket_seconds: int,
2098
+ ) -> ApiKeyTrendsData:
2099
+ since_utc = since.replace(tzinfo=timezone.utc) if since.tzinfo is None else since.astimezone(timezone.utc)
2100
+ until_utc = until.replace(tzinfo=timezone.utc) if until.tzinfo is None else until.astimezone(timezone.utc)
2101
+ if until_utc <= since_utc:
2102
+ return ApiKeyTrendsData(key_id=key_id)
2103
+
2104
+ start_epoch = (int(since_utc.timestamp()) // bucket_seconds) * bucket_seconds
2105
+ # The SQL window is exclusive of `until`, so step back one microsecond to find the last visible bucket.
2106
+ end_epoch = (int((until_utc - timedelta(microseconds=1)).timestamp()) // bucket_seconds) * bucket_seconds
2107
+ bucket_count = ((end_epoch - start_epoch) // bucket_seconds) + 1
2108
+ time_grid = [start_epoch + i * bucket_seconds for i in range(bucket_count)]
2109
+
2110
+ cost_by_bucket: dict[int, float] = {}
2111
+ tokens_by_bucket: dict[int, int] = {}
2112
+ for b in buckets:
2113
+ cost_by_bucket[b.bucket_epoch] = b.total_cost_usd
2114
+ tokens_by_bucket[b.bucket_epoch] = b.total_tokens
2115
+
2116
+ cost_points: list[ApiKeyTrendsPoint] = []
2117
+ tokens_points: list[ApiKeyTrendsPoint] = []
2118
+ for epoch in time_grid:
2119
+ dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
2120
+ cost_points.append(ApiKeyTrendsPoint(t=dt, v=round(cost_by_bucket.get(epoch, 0.0), 6)))
2121
+ tokens_points.append(ApiKeyTrendsPoint(t=dt, v=float(tokens_by_bucket.get(epoch, 0))))
2122
+
2123
+ return ApiKeyTrendsData(key_id=key_id, cost=cost_points, tokens=tokens_points)