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,2548 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import random
7
+ import time
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ from hashlib import sha1
12
+ from typing import AsyncIterator
13
+ from uuid import uuid4
14
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
15
+
16
+ from app.core.auth.refresh import RefreshError
17
+ from app.core.balancer import PERMANENT_FAILURE_CODES, account_status_for_permanent_failure
18
+ from app.core.clients.proxy import ProxyResponseError
19
+ from app.core.clients.proxy import compact_responses as core_compact_responses
20
+ from app.core.config.background_jobs import resolve_background_job_toggle
21
+ from app.core.config.dashboard_overrides import dashboard_overrides_bound
22
+ from app.core.config.settings_cache import get_settings_cache
23
+ from app.core.crypto import TokenEncryptor
24
+ from app.core.openai.model_registry import get_model_registry
25
+ from app.core.openai.requests import ResponsesCompactRequest, ResponsesReasoning
26
+ from app.core.resilience.toggles import bind_resilience_toggles
27
+ from app.core.upstream_proxy import ResolvedUpstreamRoute, resolve_upstream_route
28
+ from app.core.utils.time import naive_utc_to_epoch, utcnow
29
+ from app.db.models import Account, AccountStatus, DashboardSettings
30
+ from app.db.session import get_background_session
31
+ from app.modules.accounts.auth_manager import AuthManager
32
+ from app.modules.accounts.repository import AccountsRepository
33
+ from app.modules.automations.repository import (
34
+ AutomationJobRecord,
35
+ AutomationRunCycleRecord,
36
+ AutomationRunRecord,
37
+ AutomationsRepository,
38
+ effective_compact_request_budget_seconds,
39
+ run_stale_started_before,
40
+ )
41
+ from app.modules.proxy.account_cache import get_account_selection_cache, mark_account_routing_unavailable
42
+ from app.modules.proxy.helpers import _header_account_id
43
+ from app.modules.proxy.request_policy import resolve_wire_reasoning_effort
44
+ from app.modules.request_logs.repository import RequestLogsRepository
45
+
46
+ AUTOMATION_SCHEDULE_DAILY = "daily"
47
+ AUTOMATION_RUN_TRIGGER_SCHEDULED = "scheduled"
48
+ AUTOMATION_RUN_TRIGGER_MANUAL = "manual"
49
+ AUTOMATION_RUN_STATUS_RUNNING = "running"
50
+ AUTOMATION_RUN_STATUS_SUCCESS = "success"
51
+ AUTOMATION_RUN_STATUS_FAILED = "failed"
52
+ AUTOMATION_RUN_STATUS_PARTIAL = "partial"
53
+ DEFAULT_AUTOMATION_PROMPT = "ping"
54
+ AUTOMATION_SERVER_DEFAULT_TIMEZONE = "server_default"
55
+ AUTOMATION_SERVER_DEFAULT_TIMEZONE_ALIASES = frozenset({"server_default", "server default", "default"})
56
+ AUTOMATION_WEEKDAY_CODES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
57
+ AUTOMATION_DEFAULT_WEEKDAYS = list(AUTOMATION_WEEKDAY_CODES)
58
+ AUTOMATION_DEFAULT_THRESHOLD_MINUTES = 0
59
+ AUTOMATION_MAX_THRESHOLD_MINUTES = 240
60
+ _WEEKDAY_INDEX_TO_CODE = {index: code for index, code in enumerate(AUTOMATION_WEEKDAY_CODES)}
61
+
62
+ _RETRYABLE_ACCOUNT_FAILURE_CODES = frozenset(
63
+ {
64
+ "rate_limit_exceeded",
65
+ "usage_limit_reached",
66
+ "insufficient_quota",
67
+ "usage_not_included",
68
+ "quota_exceeded",
69
+ "account_deactivated",
70
+ "invalid_api_key",
71
+ "authentication_error",
72
+ "upstream_unavailable",
73
+ "server_error",
74
+ "upstream_error",
75
+ *PERMANENT_FAILURE_CODES.keys(),
76
+ }
77
+ )
78
+ _AUTOMATION_ALWAYS_SKIPPED_ACCOUNT_STATUSES = frozenset(
79
+ {
80
+ AccountStatus.DEACTIVATED,
81
+ AccountStatus.RATE_LIMITED,
82
+ AccountStatus.QUOTA_EXCEEDED,
83
+ }
84
+ )
85
+
86
+ logger = logging.getLogger(__name__)
87
+
88
+
89
+ @asynccontextmanager
90
+ async def _automation_accounts_refresh_scope() -> AsyncIterator[AccountsRepository]:
91
+ async with get_background_session() as session:
92
+ yield AccountsRepository(session)
93
+
94
+
95
+ async def _resolve_upstream_route_for_account(
96
+ account: Account,
97
+ *,
98
+ encryptor: TokenEncryptor,
99
+ ) -> ResolvedUpstreamRoute | None:
100
+ async with get_background_session() as session:
101
+ return await resolve_upstream_route(
102
+ session,
103
+ account_id=account.id,
104
+ operation="automation",
105
+ scope="account",
106
+ encryptor=encryptor,
107
+ )
108
+
109
+
110
+ class AutomationValidationError(ValueError):
111
+ def __init__(self, message: str, *, code: str) -> None:
112
+ super().__init__(message)
113
+ self.code = code
114
+
115
+
116
+ class AutomationNotFoundError(LookupError):
117
+ pass
118
+
119
+
120
+ class AutomationsPausedError(RuntimeError):
121
+ """M2 background jobs: ``automations_scheduler_enabled`` is False, so no run may start."""
122
+
123
+ def __init__(self) -> None:
124
+ super().__init__("Automations are paused in the dashboard settings; resume them before running a job")
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class AutomationScheduleData:
129
+ type: str
130
+ time: str
131
+ timezone: str
132
+ days: list[str]
133
+ threshold_minutes: int
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class AutomationRunData:
138
+ id: str
139
+ job_id: str
140
+ job_name: str | None
141
+ model: str | None
142
+ reasoning_effort: str | None
143
+ trigger: str
144
+ status: str
145
+ scheduled_for: datetime
146
+ started_at: datetime
147
+ finished_at: datetime | None
148
+ account_id: str | None
149
+ error_code: str | None
150
+ error_message: str | None
151
+ attempt_count: int
152
+ effective_status: str | None = None
153
+ total_accounts: int | None = None
154
+ completed_accounts: int | None = None
155
+ pending_accounts: int | None = None
156
+ cycle_key: str | None = None
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class AutomationJobData:
161
+ id: str
162
+ name: str
163
+ enabled: bool
164
+ include_paused_accounts: bool
165
+ account_scope_all: bool
166
+ schedule: AutomationScheduleData
167
+ model: str
168
+ reasoning_effort: str | None
169
+ prompt: str
170
+ account_ids: list[str]
171
+ next_run_at: datetime | None
172
+ last_run: AutomationRunData | None
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class AutomationJobsPageData:
177
+ items: list[AutomationJobData]
178
+ total: int
179
+ has_more: bool
180
+
181
+
182
+ @dataclass(frozen=True, slots=True)
183
+ class AutomationRunsPageData:
184
+ items: list[AutomationRunData]
185
+ total: int
186
+ has_more: bool
187
+
188
+
189
+ @dataclass(frozen=True, slots=True)
190
+ class AutomationJobFilterOptionsData:
191
+ account_ids: list[str]
192
+ models: list[str]
193
+ statuses: list[str]
194
+ schedule_types: list[str]
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class AutomationRunFilterOptionsData:
199
+ account_ids: list[str]
200
+ models: list[str]
201
+ statuses: list[str]
202
+ triggers: list[str]
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class AutomationRunAccountStateData:
207
+ account_id: str
208
+ status: str
209
+ run_id: str | None
210
+ scheduled_for: datetime | None
211
+ started_at: datetime | None
212
+ finished_at: datetime | None
213
+ error_code: str | None
214
+ error_message: str | None
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class AutomationRunDetailsData:
219
+ run: AutomationRunData
220
+ accounts: list[AutomationRunAccountStateData]
221
+ total_accounts: int
222
+ completed_accounts: int
223
+ pending_accounts: int
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class _AutomationRunCycleSummary:
228
+ cycle_key: str
229
+ cycle_started_at: datetime
230
+ cycle_finished_at: datetime | None
231
+ effective_status: str
232
+ total_accounts: int
233
+ completed_accounts: int
234
+ pending_accounts: int
235
+ error_code: str | None
236
+ error_message: str | None
237
+ accounts: list[AutomationRunAccountStateData]
238
+
239
+
240
+ @dataclass(frozen=True, slots=True)
241
+ class AutomationJobCreateInput:
242
+ name: str
243
+ enabled: bool
244
+ include_paused_accounts: bool
245
+ schedule_type: str
246
+ schedule_time: str
247
+ schedule_timezone: str
248
+ schedule_days: list[str] | None
249
+ schedule_threshold_minutes: int | None
250
+ model: str
251
+ reasoning_effort: str | None
252
+ prompt: str | None
253
+ account_ids: list[str]
254
+
255
+
256
+ @dataclass(frozen=True, slots=True)
257
+ class AutomationJobUpdateInput:
258
+ name: str | None = None
259
+ enabled: bool | None = None
260
+ include_paused_accounts: bool | None = None
261
+ schedule_type: str | None = None
262
+ schedule_time: str | None = None
263
+ schedule_timezone: str | None = None
264
+ schedule_days: list[str] | None = None
265
+ schedule_threshold_minutes: int | None = None
266
+ model: str | None = None
267
+ reasoning_effort: str | None = None
268
+ reasoning_effort_set: bool = False
269
+ prompt: str | None = None
270
+ account_ids: list[str] | None = None
271
+
272
+
273
+ def parse_schedule_time_hhmm(value: str) -> tuple[int, int]:
274
+ parts = value.split(":", maxsplit=1)
275
+ if len(parts) != 2:
276
+ raise AutomationValidationError("Schedule time must use HH:MM format", code="invalid_schedule_time")
277
+ hour_raw, minute_raw = parts
278
+ if len(hour_raw) != 2 or len(minute_raw) != 2:
279
+ raise AutomationValidationError("Schedule time must use HH:MM format", code="invalid_schedule_time")
280
+ if not (hour_raw.isdigit() and minute_raw.isdigit()):
281
+ raise AutomationValidationError("Schedule time must use HH:MM format", code="invalid_schedule_time")
282
+ hour = int(hour_raw)
283
+ minute = int(minute_raw)
284
+ if hour < 0 or hour > 23 or minute < 0 or minute > 59:
285
+ raise AutomationValidationError("Schedule time is out of range", code="invalid_schedule_time")
286
+ return hour, minute
287
+
288
+
289
+ def normalize_schedule_time(value: str) -> str:
290
+ hour, minute = parse_schedule_time_hhmm(value.strip())
291
+ return f"{hour:02d}:{minute:02d}"
292
+
293
+
294
+ def validate_timezone(value: str) -> str:
295
+ normalized = value.strip()
296
+ if not normalized:
297
+ raise AutomationValidationError("Schedule timezone is required", code="invalid_schedule_timezone")
298
+ if normalized.lower() in AUTOMATION_SERVER_DEFAULT_TIMEZONE_ALIASES:
299
+ return AUTOMATION_SERVER_DEFAULT_TIMEZONE
300
+ try:
301
+ ZoneInfo(normalized)
302
+ except ZoneInfoNotFoundError as exc:
303
+ raise AutomationValidationError(
304
+ f"Unknown timezone: {normalized}",
305
+ code="invalid_schedule_timezone",
306
+ ) from exc
307
+ return normalized
308
+
309
+
310
+ def normalize_schedule_days(value: list[str] | None) -> list[str]:
311
+ if value is None:
312
+ return list(AUTOMATION_DEFAULT_WEEKDAYS)
313
+
314
+ normalized: list[str] = []
315
+ for day in value:
316
+ day_code = day.strip().lower()
317
+ if not day_code:
318
+ continue
319
+ if day_code not in AUTOMATION_WEEKDAY_CODES:
320
+ raise AutomationValidationError(
321
+ f"Unsupported schedule day: {day}",
322
+ code="invalid_schedule_days",
323
+ )
324
+ if day_code not in normalized:
325
+ normalized.append(day_code)
326
+
327
+ if not normalized:
328
+ raise AutomationValidationError(
329
+ "At least one schedule day is required",
330
+ code="invalid_schedule_days",
331
+ )
332
+ return normalized
333
+
334
+
335
+ def normalize_schedule_threshold_minutes(value: int | None) -> int:
336
+ if value is None:
337
+ return AUTOMATION_DEFAULT_THRESHOLD_MINUTES
338
+ if value < 0:
339
+ raise AutomationValidationError(
340
+ "Schedule threshold must be greater than or equal to 0",
341
+ code="invalid_schedule_threshold",
342
+ )
343
+ if value > AUTOMATION_MAX_THRESHOLD_MINUTES:
344
+ raise AutomationValidationError(
345
+ f"Schedule threshold cannot exceed {AUTOMATION_MAX_THRESHOLD_MINUTES} minutes",
346
+ code="invalid_schedule_threshold",
347
+ )
348
+ return value
349
+
350
+
351
+ def compute_latest_due_slot_utc(
352
+ now_utc: datetime,
353
+ *,
354
+ schedule_time: str,
355
+ timezone_name: str,
356
+ schedule_days: list[str],
357
+ ) -> datetime:
358
+ normalized_now = _normalize_now_utc(now_utc)
359
+ hour, minute = parse_schedule_time_hhmm(schedule_time)
360
+ timezone = ZoneInfo(resolve_schedule_timezone_name(timezone_name))
361
+ allowed_days = set(normalize_schedule_days(schedule_days))
362
+
363
+ local_now = normalized_now.replace(tzinfo=UTC).astimezone(timezone)
364
+ local_candidate = datetime(
365
+ local_now.year,
366
+ local_now.month,
367
+ local_now.day,
368
+ hour,
369
+ minute,
370
+ tzinfo=timezone,
371
+ )
372
+ if local_candidate > local_now:
373
+ local_candidate -= timedelta(days=1)
374
+
375
+ for _ in range(8):
376
+ if _local_weekday_code(local_candidate) in allowed_days:
377
+ return local_candidate.astimezone(UTC).replace(tzinfo=None)
378
+ local_candidate -= timedelta(days=1)
379
+
380
+ raise RuntimeError("Failed to resolve latest due slot for automation schedule")
381
+
382
+
383
+ def compute_next_run_utc(
384
+ now_utc: datetime,
385
+ *,
386
+ schedule_time: str,
387
+ timezone_name: str,
388
+ schedule_days: list[str],
389
+ ) -> datetime:
390
+ normalized_now = _normalize_now_utc(now_utc)
391
+ hour, minute = parse_schedule_time_hhmm(schedule_time)
392
+ timezone = ZoneInfo(resolve_schedule_timezone_name(timezone_name))
393
+ allowed_days = set(normalize_schedule_days(schedule_days))
394
+
395
+ local_now = normalized_now.replace(tzinfo=UTC).astimezone(timezone)
396
+ local_candidate = datetime(
397
+ local_now.year,
398
+ local_now.month,
399
+ local_now.day,
400
+ hour,
401
+ minute,
402
+ tzinfo=timezone,
403
+ )
404
+ if local_candidate <= local_now:
405
+ local_candidate += timedelta(days=1)
406
+
407
+ for _ in range(8):
408
+ if _local_weekday_code(local_candidate) in allowed_days:
409
+ return local_candidate.astimezone(UTC).replace(tzinfo=None)
410
+ local_candidate += timedelta(days=1)
411
+
412
+ raise RuntimeError("Failed to resolve next run for automation schedule")
413
+
414
+
415
+ def _normalize_now_utc(value: datetime) -> datetime:
416
+ if value.tzinfo is None:
417
+ return value
418
+ return value.astimezone(UTC).replace(tzinfo=None)
419
+
420
+
421
+ def _local_weekday_code(value: datetime) -> str:
422
+ return _WEEKDAY_INDEX_TO_CODE[value.weekday()]
423
+
424
+
425
+ def resolve_schedule_timezone_name(value: str) -> str:
426
+ normalized = value.strip()
427
+ if normalized.lower() in AUTOMATION_SERVER_DEFAULT_TIMEZONE_ALIASES:
428
+ return _resolve_server_timezone_name()
429
+ return normalized
430
+
431
+
432
+ def _resolve_server_timezone_name() -> str:
433
+ candidates: list[str] = []
434
+
435
+ env_timezone = os.getenv("TZ", "").strip()
436
+ if env_timezone:
437
+ candidates.append(env_timezone)
438
+
439
+ local_timezone = datetime.now().astimezone().tzinfo
440
+ local_key = getattr(local_timezone, "key", None)
441
+ if isinstance(local_key, str) and local_key.strip():
442
+ candidates.append(local_key.strip())
443
+
444
+ if local_timezone is not None:
445
+ local_label = str(local_timezone).strip()
446
+ if local_label:
447
+ candidates.append(local_label)
448
+
449
+ candidates.append("UTC")
450
+
451
+ for candidate in candidates:
452
+ try:
453
+ ZoneInfo(candidate)
454
+ return candidate
455
+ except ZoneInfoNotFoundError:
456
+ continue
457
+
458
+ return "UTC"
459
+
460
+
461
+ class AutomationsService:
462
+ def __init__(
463
+ self,
464
+ repository: AutomationsRepository,
465
+ accounts_repository: AccountsRepository,
466
+ request_logs_repository: RequestLogsRepository | None = None,
467
+ ) -> None:
468
+ self._repository = repository
469
+ self._accounts_repository = accounts_repository
470
+ self._request_logs_repository = request_logs_repository
471
+ self._auth_manager = AuthManager(
472
+ accounts_repository,
473
+ refresh_repo_factory=_automation_accounts_refresh_scope,
474
+ )
475
+ self._encryptor = TokenEncryptor()
476
+
477
+ async def list_jobs(self, *, now_utc: datetime | None = None) -> list[AutomationJobData]:
478
+ now = now_utc or utcnow()
479
+ jobs = await self._repository.list_jobs()
480
+ latest_runs = await self._repository.get_latest_runs_by_job_ids([job.id for job in jobs])
481
+ latest_run_data = await self._enrich_runs_with_progress(
482
+ list(latest_runs.values()),
483
+ apply_cycle_terminal_overrides=True,
484
+ )
485
+ latest_run_data_by_job_id = {run.job_id: run for run in latest_run_data}
486
+ return [self._to_job_data(job, latest_run_data_by_job_id.get(job.id), now_utc=now) for job in jobs]
487
+
488
+ async def list_jobs_page(
489
+ self,
490
+ *,
491
+ limit: int,
492
+ offset: int,
493
+ search: str | None = None,
494
+ account_ids: list[str] | None = None,
495
+ models: list[str] | None = None,
496
+ statuses: list[str] | None = None,
497
+ schedule_types: list[str] | None = None,
498
+ now_utc: datetime | None = None,
499
+ ) -> AutomationJobsPageData:
500
+ now = now_utc or utcnow()
501
+ jobs, total = await self._repository.list_jobs_page(
502
+ limit=limit,
503
+ offset=offset,
504
+ search=search,
505
+ account_ids=account_ids,
506
+ models=models,
507
+ statuses=_normalize_job_status_filters(statuses),
508
+ schedule_types=schedule_types,
509
+ )
510
+ latest_runs = await self._repository.get_latest_runs_by_job_ids([job.id for job in jobs])
511
+ latest_run_data = await self._enrich_runs_with_progress(
512
+ list(latest_runs.values()),
513
+ apply_cycle_terminal_overrides=True,
514
+ )
515
+ latest_run_data_by_job_id = {run.job_id: run for run in latest_run_data}
516
+ items = [self._to_job_data(job, latest_run_data_by_job_id.get(job.id), now_utc=now) for job in jobs]
517
+ return AutomationJobsPageData(items=items, total=total, has_more=offset + limit < total)
518
+
519
+ async def list_job_filter_options(
520
+ self,
521
+ *,
522
+ search: str | None = None,
523
+ account_ids: list[str] | None = None,
524
+ models: list[str] | None = None,
525
+ statuses: list[str] | None = None,
526
+ schedule_types: list[str] | None = None,
527
+ ) -> AutomationJobFilterOptionsData:
528
+ options = await self._repository.list_job_filter_options(
529
+ search=search,
530
+ account_ids=account_ids,
531
+ models=models,
532
+ statuses=_normalize_job_status_filters(statuses),
533
+ schedule_types=schedule_types,
534
+ )
535
+ accounts = await self._accounts_repository.list_accounts()
536
+ available_account_ids = sorted(
537
+ {
538
+ *options.account_ids,
539
+ *(account.id for account in accounts),
540
+ }
541
+ )
542
+ return AutomationJobFilterOptionsData(
543
+ account_ids=available_account_ids,
544
+ models=options.models,
545
+ statuses=options.statuses,
546
+ schedule_types=options.schedule_types,
547
+ )
548
+
549
+ async def create_job(
550
+ self, payload: AutomationJobCreateInput, *, now_utc: datetime | None = None
551
+ ) -> AutomationJobData:
552
+ normalized = await self._normalize_create_input(payload)
553
+ prompt = normalized.prompt if normalized.prompt is not None else DEFAULT_AUTOMATION_PROMPT
554
+ record = await self._repository.create_job(
555
+ name=normalized.name,
556
+ enabled=normalized.enabled,
557
+ include_paused_accounts=normalized.include_paused_accounts,
558
+ schedule_type=normalized.schedule_type,
559
+ schedule_time=normalized.schedule_time,
560
+ schedule_timezone=normalized.schedule_timezone,
561
+ schedule_days=normalized.schedule_days or list(AUTOMATION_DEFAULT_WEEKDAYS),
562
+ schedule_threshold_minutes=normalized.schedule_threshold_minutes
563
+ if normalized.schedule_threshold_minutes is not None
564
+ else AUTOMATION_DEFAULT_THRESHOLD_MINUTES,
565
+ model=normalized.model,
566
+ reasoning_effort=normalized.reasoning_effort,
567
+ prompt=prompt,
568
+ account_ids=normalized.account_ids,
569
+ )
570
+ now = now_utc or utcnow()
571
+ return self._to_job_data(record, None, now_utc=now)
572
+
573
+ async def update_job(
574
+ self,
575
+ job_id: str,
576
+ payload: AutomationJobUpdateInput,
577
+ *,
578
+ now_utc: datetime | None = None,
579
+ ) -> AutomationJobData:
580
+ existing = await self._repository.get_job(job_id)
581
+ if existing is None:
582
+ raise AutomationNotFoundError(job_id)
583
+ normalized = await self._normalize_update_input(payload, existing=existing)
584
+ updated = await self._repository.update_job(
585
+ job_id,
586
+ name=normalized.name,
587
+ enabled=normalized.enabled,
588
+ include_paused_accounts=normalized.include_paused_accounts,
589
+ schedule_type=normalized.schedule_type,
590
+ schedule_time=normalized.schedule_time,
591
+ schedule_timezone=normalized.schedule_timezone,
592
+ schedule_days=normalized.schedule_days,
593
+ schedule_threshold_minutes=normalized.schedule_threshold_minutes,
594
+ model=normalized.model,
595
+ reasoning_effort=normalized.reasoning_effort,
596
+ reasoning_effort_set=normalized.reasoning_effort_set,
597
+ prompt=normalized.prompt,
598
+ account_ids=normalized.account_ids,
599
+ )
600
+ if updated is None:
601
+ raise AutomationNotFoundError(job_id)
602
+ latest_runs = await self._repository.get_latest_runs_by_job_ids([job_id])
603
+ latest_run_data = await self._enrich_runs_with_progress(
604
+ list(latest_runs.values()),
605
+ apply_cycle_terminal_overrides=True,
606
+ )
607
+ latest_run_data_by_job_id = {run.job_id: run for run in latest_run_data}
608
+ now = now_utc or utcnow()
609
+ return self._to_job_data(updated, latest_run_data_by_job_id.get(job_id), now_utc=now)
610
+
611
+ async def delete_job(self, job_id: str) -> bool:
612
+ return await self._repository.delete_job(job_id)
613
+
614
+ async def list_runs(self, job_id: str, *, limit: int = 20) -> list[AutomationRunData]:
615
+ job = await self._repository.get_job(job_id)
616
+ if job is None:
617
+ raise AutomationNotFoundError(job_id)
618
+ runs = await self._repository.list_runs(job_id, limit=limit)
619
+ items = await self._enrich_runs_with_progress(runs)
620
+ return sorted(items, key=lambda entry: (entry.started_at, entry.id), reverse=True)
621
+
622
+ async def list_runs_page(
623
+ self,
624
+ *,
625
+ limit: int,
626
+ offset: int,
627
+ search: str | None = None,
628
+ account_ids: list[str] | None = None,
629
+ models: list[str] | None = None,
630
+ statuses: list[str] | None = None,
631
+ triggers: list[str] | None = None,
632
+ job_ids: list[str] | None = None,
633
+ ) -> AutomationRunsPageData:
634
+ runs, total = await self._repository.list_run_cycles_page(
635
+ limit=limit,
636
+ offset=offset,
637
+ now_utc=utcnow(),
638
+ search=search,
639
+ account_ids=account_ids,
640
+ models=models,
641
+ statuses=_normalize_run_status_filters(statuses),
642
+ triggers=_normalize_run_trigger_filters(triggers),
643
+ job_ids=job_ids,
644
+ )
645
+ items = await self._enrich_runs_with_progress(runs, apply_cycle_terminal_overrides=True)
646
+ return AutomationRunsPageData(items=items, total=total, has_more=offset + limit < total)
647
+
648
+ async def list_run_filter_options(
649
+ self,
650
+ *,
651
+ search: str | None = None,
652
+ account_ids: list[str] | None = None,
653
+ models: list[str] | None = None,
654
+ statuses: list[str] | None = None,
655
+ triggers: list[str] | None = None,
656
+ job_ids: list[str] | None = None,
657
+ ) -> AutomationRunFilterOptionsData:
658
+ normalized_statuses = _normalize_run_status_filters(statuses)
659
+ normalized_triggers = _normalize_run_trigger_filters(triggers)
660
+ options = await self._repository.list_run_filter_options(
661
+ now_utc=utcnow(),
662
+ search=search,
663
+ account_ids=account_ids,
664
+ models=models,
665
+ statuses=normalized_statuses,
666
+ triggers=normalized_triggers,
667
+ job_ids=job_ids,
668
+ )
669
+ has_active_filters = bool(
670
+ (search or "").strip() or account_ids or models or normalized_statuses or normalized_triggers or job_ids
671
+ )
672
+ if has_active_filters:
673
+ available_account_ids = sorted(options.account_ids)
674
+ else:
675
+ accounts = await self._accounts_repository.list_accounts()
676
+ available_account_ids = sorted(
677
+ {
678
+ *options.account_ids,
679
+ *(account.id for account in accounts),
680
+ }
681
+ )
682
+ return AutomationRunFilterOptionsData(
683
+ account_ids=available_account_ids,
684
+ models=options.models,
685
+ statuses=_all_run_statuses(),
686
+ triggers=_all_run_triggers(),
687
+ )
688
+
689
+ async def get_run_details(self, run_id: str) -> AutomationRunDetailsData:
690
+ run = await self._repository.get_run(run_id)
691
+ if run is None:
692
+ raise AutomationNotFoundError(run_id)
693
+ jobs_by_id = await self._repository.get_jobs_by_ids([run.job_id])
694
+ job = jobs_by_id.get(run.job_id)
695
+ if job is None:
696
+ raise AutomationNotFoundError(run.job_id)
697
+ summary = await self._build_cycle_summary_for_run(
698
+ run=run,
699
+ job=job,
700
+ now_utc=utcnow(),
701
+ )
702
+ return AutomationRunDetailsData(
703
+ run=self._to_run_data(run, summary=summary, apply_cycle_terminal_overrides=True),
704
+ accounts=summary.accounts,
705
+ total_accounts=summary.total_accounts,
706
+ completed_accounts=summary.completed_accounts,
707
+ pending_accounts=summary.pending_accounts,
708
+ )
709
+
710
+ async def run_now(self, job_id: str, *, now_utc: datetime | None = None) -> AutomationRunData:
711
+ job = await self._repository.get_job(job_id)
712
+ if job is None:
713
+ raise AutomationNotFoundError(job_id)
714
+ # M2 background jobs: a manual run while the scheduler is paused would
715
+ # make the "paused" label a lie, so refuse with the same toggle the
716
+ # scheduler tick reads.
717
+ if not resolve_background_job_toggle(await get_settings_cache().get(), "automations_scheduler_enabled"):
718
+ raise AutomationsPausedError()
719
+ now = now_utc or utcnow()
720
+ cycle_id = uuid4().hex
721
+ cycle_key = _manual_cycle_key(job.id, cycle_id)
722
+ account_ids = await self._resolve_job_account_ids_for_dispatch(job)
723
+ threshold = max(0, job.schedule_threshold_minutes)
724
+ cycle_window_end = now + timedelta(minutes=threshold)
725
+ dispatch_plan = _build_dispatch_plan(
726
+ job_id=job.id,
727
+ due_slot=now,
728
+ account_ids=account_ids,
729
+ threshold_minutes=threshold,
730
+ )
731
+ if not dispatch_plan:
732
+ initial_runs = [(f"manual:{job.id}:{cycle_id}:none", now, None)]
733
+ else:
734
+ initial_runs = [
735
+ (
736
+ _manual_slot_key(job.id, cycle_id, account_id),
737
+ scheduled_for,
738
+ account_id,
739
+ )
740
+ for account_id, scheduled_for in dispatch_plan
741
+ ]
742
+ cycle, claims = await self._repository.create_run_cycle_with_runs(
743
+ cycle_key=cycle_key,
744
+ job_id=job.id,
745
+ trigger=AUTOMATION_RUN_TRIGGER_MANUAL,
746
+ cycle_expected_accounts=len(dispatch_plan),
747
+ cycle_window_end=cycle_window_end,
748
+ accounts=dispatch_plan,
749
+ runs=initial_runs,
750
+ started_at=now,
751
+ include_paused_accounts=job.include_paused_accounts,
752
+ )
753
+ if not cycle.accounts:
754
+ if not claims:
755
+ raise RuntimeError("Failed to claim manual automation run")
756
+ return await self._execute_claimed_run(job, claims[0])
757
+
758
+ representative_claim: AutomationRunRecord | None = None
759
+ has_delayed_dispatches = any(cycle_account.scheduled_for > now for cycle_account in cycle.accounts)
760
+ for claim in claims:
761
+ if representative_claim is None or not has_delayed_dispatches:
762
+ representative_claim = claim
763
+ if representative_claim is not None:
764
+ await self._run_due_manual_runs(now_utc=now, cycle_key=cycle_key)
765
+ representative_run = await self._repository.get_run(representative_claim.id)
766
+ if representative_run is None:
767
+ representative_run = representative_claim
768
+ summary = await self._build_cycle_summary_for_run(
769
+ run=representative_run,
770
+ job=job,
771
+ now_utc=now,
772
+ )
773
+ return self._to_run_data(
774
+ representative_run,
775
+ summary=summary,
776
+ apply_cycle_terminal_overrides=True,
777
+ )
778
+ raise RuntimeError("Failed to claim manual automation run")
779
+
780
+ async def run_due_jobs(
781
+ self, *, now_utc: datetime | None = None, dashboard_settings: DashboardSettings | None = None
782
+ ) -> int:
783
+ # Scheduler entry: bind the dashboard snapshot so the compact budget the
784
+ # runs use is the dashboard value (C2-1), as it is on the request path.
785
+ # M2 background jobs: the scheduler took that snapshot before its lock
786
+ # and passes it in, so the locked body never awaits the settings cache;
787
+ # only callers without one (tests, ad-hoc runs) read it here.
788
+ snapshot = dashboard_settings if dashboard_settings is not None else await get_settings_cache().get()
789
+ # The same snapshot carries the pause toggle; a paused scheduler
790
+ # dispatches nothing (scheduled cycles or manual runs).
791
+ if not resolve_background_job_toggle(snapshot, "automations_scheduler_enabled"):
792
+ return 0
793
+ with dashboard_overrides_bound(snapshot):
794
+ return await self._run_due_jobs(now_utc=now_utc)
795
+
796
+ async def _run_due_jobs(self, *, now_utc: datetime | None = None) -> int:
797
+ now = now_utc or utcnow()
798
+ executed = await self._run_due_manual_runs(now_utc=now)
799
+ jobs_by_id = {job.id: job for job in await self._repository.list_enabled_jobs()}
800
+ due_cycle_job_ids = await self._repository.list_due_scheduled_run_cycle_job_ids(now_utc=now)
801
+ missing_due_cycle_job_ids = [job_id for job_id in due_cycle_job_ids if job_id not in jobs_by_id]
802
+ if missing_due_cycle_job_ids:
803
+ jobs_by_id.update(await self._repository.get_jobs_by_ids(missing_due_cycle_job_ids))
804
+ jobs = list(jobs_by_id.values())
805
+ for job in jobs:
806
+ cycles_to_process: dict[str, tuple[AutomationRunCycleRecord, datetime]] = {}
807
+ due_cycles = await self._repository.list_due_scheduled_run_cycles(job_id=job.id, now_utc=now)
808
+ for due_cycle in due_cycles:
809
+ cycle_due_slot = _parse_scheduled_cycle_due_slot(due_cycle.cycle_key, job_id=job.id)
810
+ if cycle_due_slot is None:
811
+ continue
812
+ cycles_to_process[due_cycle.cycle_key] = (due_cycle, cycle_due_slot)
813
+ if job.enabled:
814
+ due_slot = compute_latest_due_slot_utc(
815
+ now,
816
+ schedule_time=job.schedule_time,
817
+ timezone_name=job.schedule_timezone,
818
+ schedule_days=job.schedule_days,
819
+ )
820
+ cycle_key = _scheduled_cycle_key(job.id, due_slot)
821
+ if cycle_key not in cycles_to_process:
822
+ cycle = await self._repository.get_run_cycle(cycle_key=cycle_key)
823
+ if cycle is None and due_slot < _normalize_now_utc(job.updated_at):
824
+ pass
825
+ else:
826
+ if cycle is None:
827
+ cycle = await self._get_or_create_scheduled_cycle(job=job, due_slot=due_slot)
828
+ cycles_to_process[cycle.cycle_key] = (cycle, due_slot)
829
+ for cycle, cycle_due_slot in cycles_to_process.values():
830
+ executed += await self._run_due_scheduled_cycle(
831
+ job=job,
832
+ cycle=cycle,
833
+ due_slot=cycle_due_slot,
834
+ now_utc=now,
835
+ )
836
+ return executed
837
+
838
+ async def _run_due_scheduled_cycle(
839
+ self,
840
+ *,
841
+ job: AutomationJobRecord,
842
+ cycle: AutomationRunCycleRecord,
843
+ due_slot: datetime,
844
+ now_utc: datetime,
845
+ ) -> int:
846
+ cycle_key = cycle.cycle_key
847
+ existing_cycle_runs = await self._repository.list_runs_for_cycle_key(cycle_key=cycle_key)
848
+ fallback_budget_seconds = effective_compact_request_budget_seconds()
849
+ if not cycle.accounts:
850
+ if existing_cycle_runs:
851
+ existing_cycle_run = existing_cycle_runs[0]
852
+ existing_run_is_stale = (
853
+ existing_cycle_run.status == AUTOMATION_RUN_STATUS_RUNNING
854
+ and _is_reclaimable_running_claim(
855
+ existing_cycle_run,
856
+ now_utc=now_utc,
857
+ fallback_budget_seconds=fallback_budget_seconds,
858
+ )
859
+ )
860
+ if not existing_run_is_stale:
861
+ return 0
862
+ claim = await self._repository.claim_scheduled_cycle_run_execution(
863
+ run_id=existing_cycle_run.id,
864
+ observed_started_at=existing_cycle_run.started_at,
865
+ claimed_started_at=now_utc,
866
+ stale_started_before=_run_stale_started_before(
867
+ existing_cycle_run,
868
+ now_utc=now_utc,
869
+ fallback_budget_seconds=fallback_budget_seconds,
870
+ ),
871
+ )
872
+ if claim is None:
873
+ return 0
874
+ await self._repository.complete_run(
875
+ claim.id,
876
+ status=AUTOMATION_RUN_STATUS_FAILED,
877
+ finished_at=utcnow(),
878
+ account_id=None,
879
+ error_code="no_available_accounts",
880
+ error_message="No available accounts configured for automation job",
881
+ attempt_count=claim.attempt_count,
882
+ )
883
+ return 1
884
+ claim = await self._repository.claim_run(
885
+ job_id=job.id,
886
+ trigger=AUTOMATION_RUN_TRIGGER_SCHEDULED,
887
+ slot_key=_scheduled_slot_key(job.id, due_slot=due_slot),
888
+ cycle_key=cycle_key,
889
+ cycle_expected_accounts=cycle.cycle_expected_accounts,
890
+ cycle_window_end=cycle.cycle_window_end,
891
+ scheduled_for=due_slot,
892
+ started_at=now_utc,
893
+ )
894
+ if claim is None:
895
+ return 0
896
+ await self._execute_claimed_run(job, claim)
897
+ return 1
898
+ cycle_account_id_by_slot_key = {
899
+ _scheduled_slot_key(
900
+ job.id,
901
+ account_id=cycle_account.account_id,
902
+ due_slot=due_slot,
903
+ ): cycle_account.account_id
904
+ for cycle_account in cycle.accounts
905
+ }
906
+ existing_cycle_runs_by_account: dict[str, AutomationRunRecord] = {}
907
+ for cycle_run in existing_cycle_runs:
908
+ account_id = cycle_account_id_by_slot_key.get(cycle_run.slot_key) or cycle_run.account_id
909
+ if account_id is not None:
910
+ existing_cycle_runs_by_account[account_id] = cycle_run
911
+ eligible_cycle_account_ids = await self._resolve_eligible_account_ids(
912
+ [cycle_account.account_id for cycle_account in cycle.accounts],
913
+ include_paused_accounts=cycle.include_paused_accounts,
914
+ now_utc=now_utc,
915
+ )
916
+ executed = 0
917
+ cycle_expected_accounts = cycle.cycle_expected_accounts
918
+ for cycle_account in cycle.accounts:
919
+ if cycle_account.scheduled_for > now_utc:
920
+ continue
921
+ existing_cycle_run = existing_cycle_runs_by_account.get(cycle_account.account_id)
922
+ if cycle_account.account_id not in eligible_cycle_account_ids:
923
+ if existing_cycle_run is None:
924
+ deleted = await self._repository.delete_run_cycle_account(
925
+ cycle_key=cycle_key,
926
+ account_id=cycle_account.account_id,
927
+ )
928
+ if deleted:
929
+ cycle_expected_accounts = max(0, cycle_expected_accounts - 1)
930
+ continue
931
+ if existing_cycle_run.status != AUTOMATION_RUN_STATUS_RUNNING:
932
+ continue
933
+ if _is_unclaimed_run_placeholder(existing_cycle_run):
934
+ deleted = await self._repository.delete_run_cycle_account(
935
+ cycle_key=cycle_key,
936
+ account_id=cycle_account.account_id,
937
+ )
938
+ if deleted:
939
+ cycle_expected_accounts = max(0, cycle_expected_accounts - 1)
940
+ continue
941
+ existing_run_is_stale = _is_reclaimable_running_claim(
942
+ existing_cycle_run,
943
+ now_utc=now_utc,
944
+ fallback_budget_seconds=fallback_budget_seconds,
945
+ )
946
+ if not existing_run_is_stale:
947
+ continue
948
+ claim = await self._repository.claim_scheduled_cycle_run_execution(
949
+ run_id=existing_cycle_run.id,
950
+ observed_started_at=existing_cycle_run.started_at,
951
+ claimed_started_at=now_utc,
952
+ stale_started_before=_run_stale_started_before(
953
+ existing_cycle_run,
954
+ now_utc=now_utc,
955
+ fallback_budget_seconds=fallback_budget_seconds,
956
+ ),
957
+ )
958
+ if claim is None:
959
+ continue
960
+ await self._repository.complete_run(
961
+ claim.id,
962
+ status=AUTOMATION_RUN_STATUS_FAILED,
963
+ finished_at=utcnow(),
964
+ account_id=cycle_account.account_id,
965
+ error_code="no_available_accounts",
966
+ error_message="No available accounts configured for automation job",
967
+ attempt_count=claim.attempt_count,
968
+ )
969
+ executed += 1
970
+ continue
971
+ if existing_cycle_run is not None:
972
+ existing_run_is_stale = _is_reclaimable_running_claim(
973
+ existing_cycle_run,
974
+ now_utc=now_utc,
975
+ fallback_budget_seconds=fallback_budget_seconds,
976
+ )
977
+ if not existing_run_is_stale:
978
+ continue
979
+ claim = await self._repository.claim_scheduled_cycle_run_execution(
980
+ run_id=existing_cycle_run.id,
981
+ observed_started_at=existing_cycle_run.started_at,
982
+ claimed_started_at=now_utc,
983
+ stale_started_before=_run_stale_started_before(
984
+ existing_cycle_run,
985
+ now_utc=now_utc,
986
+ fallback_budget_seconds=fallback_budget_seconds,
987
+ ),
988
+ )
989
+ if claim is None:
990
+ continue
991
+ else:
992
+ claimed_started_at = max(now_utc, cycle_account.scheduled_for + timedelta(microseconds=1))
993
+ claim_result = await self._repository.claim_scheduled_cycle_account_run(
994
+ job_id=job.id,
995
+ trigger=AUTOMATION_RUN_TRIGGER_SCHEDULED,
996
+ slot_key=_scheduled_slot_key(
997
+ job.id,
998
+ account_id=cycle_account.account_id,
999
+ due_slot=due_slot,
1000
+ ),
1001
+ cycle_key=cycle_key,
1002
+ cycle_expected_accounts=cycle_expected_accounts,
1003
+ cycle_window_end=cycle.cycle_window_end,
1004
+ scheduled_for=cycle_account.scheduled_for,
1005
+ started_at=claimed_started_at,
1006
+ account_id=cycle_account.account_id,
1007
+ )
1008
+ if claim_result.run is None:
1009
+ if not claim_result.snapshot_account_exists:
1010
+ cycle_expected_accounts = max(0, cycle_expected_accounts - 1)
1011
+ continue
1012
+ claim = claim_result.run
1013
+ await self._execute_claimed_run(job, claim, forced_account_id=cycle_account.account_id)
1014
+ executed += 1
1015
+ return executed
1016
+
1017
+ async def _run_due_manual_runs(self, *, now_utc: datetime, cycle_key: str | None = None) -> int:
1018
+ due_runs = await self._repository.list_due_manual_runs(now_utc=now_utc, cycle_key=cycle_key)
1019
+ if not due_runs:
1020
+ return 0
1021
+ fallback_budget_seconds = effective_compact_request_budget_seconds()
1022
+ jobs_by_id = await self._repository.get_jobs_by_ids([run.job_id for run in due_runs])
1023
+ cycles_by_key: dict[str, AutomationRunCycleRecord | None] = {}
1024
+ executed = 0
1025
+ for run in due_runs:
1026
+ job = jobs_by_id.get(run.job_id)
1027
+ if job is None:
1028
+ continue
1029
+ include_paused_accounts = job.include_paused_accounts
1030
+ normalized_cycle_key = _normalize_legacy_manual_cycle_key(run.cycle_key)
1031
+ if normalized_cycle_key is not None:
1032
+ if normalized_cycle_key not in cycles_by_key:
1033
+ cycles_by_key[normalized_cycle_key] = await self._repository.get_run_cycle(
1034
+ cycle_key=normalized_cycle_key
1035
+ )
1036
+ cycle = cycles_by_key[normalized_cycle_key]
1037
+ if cycle is not None:
1038
+ include_paused_accounts = cycle.include_paused_accounts
1039
+ account_id = await self._resolve_manual_run_dispatch_account_id(run, job=job)
1040
+ if account_id is None:
1041
+ continue
1042
+ account = await self._accounts_repository.get_by_id(account_id)
1043
+ if account is not None:
1044
+ await self._reactivate_accounts_if_reset_elapsed([account], now_utc=now_utc)
1045
+ if account is None or not self._is_account_eligible_for_automation(
1046
+ account,
1047
+ include_paused_accounts=include_paused_accounts,
1048
+ ):
1049
+ if _is_unclaimed_run_placeholder(run):
1050
+ await self._repository.skip_unclaimed_manual_run_placeholder(
1051
+ run.id,
1052
+ cycle_key=run.cycle_key,
1053
+ account_id=account_id,
1054
+ observed_started_at=run.started_at,
1055
+ skipped_at=now_utc,
1056
+ )
1057
+ continue
1058
+ claimed_started_at = max(
1059
+ utcnow(),
1060
+ run.started_at + timedelta(microseconds=1),
1061
+ run.scheduled_for + timedelta(microseconds=1),
1062
+ )
1063
+ claimed_run = await self._repository.claim_manual_run_execution(
1064
+ run.id,
1065
+ observed_started_at=run.started_at,
1066
+ claimed_started_at=claimed_started_at,
1067
+ stale_started_before=_run_stale_started_before(
1068
+ run,
1069
+ now_utc=now_utc,
1070
+ fallback_budget_seconds=fallback_budget_seconds,
1071
+ ),
1072
+ )
1073
+ if claimed_run is None:
1074
+ continue
1075
+ await self._execute_claimed_run(job, claimed_run, forced_account_id=claimed_run.account_id)
1076
+ executed += 1
1077
+ return executed
1078
+
1079
+ async def _resolve_manual_run_dispatch_account_id(
1080
+ self,
1081
+ run: AutomationRunRecord,
1082
+ *,
1083
+ job: AutomationJobRecord,
1084
+ ) -> str | None:
1085
+ if run.account_id is not None:
1086
+ return run.account_id
1087
+ if not _is_unclaimed_run_placeholder(run):
1088
+ return None
1089
+ cycle_key = _normalize_legacy_manual_cycle_key(run.cycle_key)
1090
+ if cycle_key is None:
1091
+ return None
1092
+ cycle = await self._repository.get_run_cycle(cycle_key=cycle_key)
1093
+ if cycle is None:
1094
+ return None
1095
+ return self._resolve_manual_cycle_run_account_id(
1096
+ run,
1097
+ job=job,
1098
+ cycle_key=cycle.cycle_key,
1099
+ expected_account_ids=[entry.account_id for entry in cycle.accounts],
1100
+ )
1101
+
1102
+ async def _get_or_create_scheduled_cycle(
1103
+ self,
1104
+ *,
1105
+ job: AutomationJobRecord,
1106
+ due_slot: datetime,
1107
+ ) -> AutomationRunCycleRecord:
1108
+ cycle_key = _scheduled_cycle_key(job.id, due_slot)
1109
+ existing_cycle = await self._repository.get_run_cycle(cycle_key=cycle_key)
1110
+ if existing_cycle is not None:
1111
+ return existing_cycle
1112
+
1113
+ threshold = max(0, job.schedule_threshold_minutes)
1114
+ account_ids = await self._resolve_job_account_ids_for_dispatch(job)
1115
+ dispatch_plan = _build_dispatch_plan(
1116
+ job_id=job.id,
1117
+ due_slot=due_slot,
1118
+ account_ids=account_ids,
1119
+ threshold_minutes=threshold,
1120
+ )
1121
+ return await self._repository.create_run_cycle(
1122
+ cycle_key=cycle_key,
1123
+ job_id=job.id,
1124
+ trigger=AUTOMATION_RUN_TRIGGER_SCHEDULED,
1125
+ cycle_expected_accounts=len(dispatch_plan),
1126
+ cycle_window_end=due_slot + timedelta(minutes=threshold),
1127
+ accounts=dispatch_plan,
1128
+ include_paused_accounts=job.include_paused_accounts,
1129
+ )
1130
+
1131
+ async def _execute_claimed_run(
1132
+ self,
1133
+ job: AutomationJobRecord,
1134
+ run: AutomationRunRecord,
1135
+ *,
1136
+ forced_account_id: str | None = None,
1137
+ ) -> AutomationRunData:
1138
+ attempt_count = 0
1139
+ last_error_code: str | None = "no_available_accounts"
1140
+ last_error_message: str | None = "No available accounts configured for automation job"
1141
+ last_attempted_account_id: str | None = forced_account_id
1142
+ cached_accounts_by_id: dict[str, Account] | None = None
1143
+ account_ids_to_try: list[str] = []
1144
+ cycle_account_ids: list[str] | None = None
1145
+ forced_account_id_for_priority = forced_account_id
1146
+ include_paused_accounts = job.include_paused_accounts
1147
+ if run.cycle_key:
1148
+ cycle = await self._repository.get_run_cycle(cycle_key=run.cycle_key)
1149
+ if cycle is not None:
1150
+ include_paused_accounts = cycle.include_paused_accounts
1151
+ cycle_account_ids = [entry.account_id for entry in cycle.accounts]
1152
+ account_ids_to_try = cycle_account_ids
1153
+ if forced_account_id not in account_ids_to_try:
1154
+ forced_account_id_for_priority = None
1155
+ if cycle_account_ids is None and not account_ids_to_try:
1156
+ account_ids_to_try = list(job.account_ids)
1157
+ if cycle_account_ids is None and not account_ids_to_try:
1158
+ accounts = await self._accounts_repository.list_accounts()
1159
+ account_ids_to_try = [
1160
+ account.id
1161
+ for account in accounts
1162
+ if self._is_account_eligible_for_automation(
1163
+ account,
1164
+ include_paused_accounts=include_paused_accounts,
1165
+ )
1166
+ ]
1167
+ cached_accounts_by_id = {account.id: account for account in accounts}
1168
+ account_ids_to_try = _prioritize_forced_account(account_ids_to_try, forced_account_id_for_priority)
1169
+ run_model = run.model or job.model
1170
+ run_reasoning_effort = run.reasoning_effort
1171
+ # Client-plane efforts (``ultra``) must be aliased to their wire-safe
1172
+ # value (``max``) here because the compact ping bypasses the proxy
1173
+ # request-policy path that performs this rewrite for proxied traffic.
1174
+ wire_reasoning_effort = (
1175
+ resolve_wire_reasoning_effort(run_reasoning_effort) if run_reasoning_effort is not None else None
1176
+ )
1177
+ run_prompt = run.prompt or job.prompt
1178
+
1179
+ for account_id in account_ids_to_try:
1180
+ request_started_at: float | None = None
1181
+ if cached_accounts_by_id is None:
1182
+ account = await self._accounts_repository.get_by_id(account_id)
1183
+ else:
1184
+ account = cached_accounts_by_id.get(account_id)
1185
+ if account is None:
1186
+ last_error_code = "account_not_found"
1187
+ last_error_message = f"Account '{account_id}' not found"
1188
+ continue
1189
+ if not self._is_account_eligible_for_automation(
1190
+ account,
1191
+ include_paused_accounts=include_paused_accounts,
1192
+ ):
1193
+ continue
1194
+
1195
+ try:
1196
+ attempt_count += 1
1197
+ last_attempted_account_id = account_id
1198
+ account = await self._auth_manager.ensure_fresh(account)
1199
+ access_token = self._encryptor.decrypt(account.access_token_encrypted)
1200
+ route = await _resolve_upstream_route_for_account(
1201
+ account,
1202
+ encryptor=self._encryptor,
1203
+ )
1204
+ ping_request = ResponsesCompactRequest(
1205
+ model=run_model,
1206
+ input=run_prompt,
1207
+ instructions="Automation ping",
1208
+ reasoning=ResponsesReasoning(effort=wire_reasoning_effort) if wire_reasoning_effort else None,
1209
+ )
1210
+ request_started_at = time.monotonic()
1211
+ # C2-3 resilience toggles: background job, no request snapshot
1212
+ # to inherit; take one so the breaker gate follows the dashboard.
1213
+ bind_resilience_toggles(await get_settings_cache().get())
1214
+ compact_response = await asyncio.wait_for(
1215
+ core_compact_responses(
1216
+ ping_request,
1217
+ headers={},
1218
+ access_token=access_token,
1219
+ account_id=_header_account_id(account.chatgpt_account_id),
1220
+ route=route,
1221
+ allow_direct_egress=route is None,
1222
+ ),
1223
+ timeout=_automation_compact_request_timeout_seconds(run),
1224
+ )
1225
+ latency_ms = _elapsed_ms(request_started_at)
1226
+ request_id = _automation_request_id(getattr(compact_response, "id", None), run.id, attempt_count)
1227
+ (
1228
+ input_tokens,
1229
+ output_tokens,
1230
+ cached_input_tokens,
1231
+ reasoning_tokens,
1232
+ service_tier,
1233
+ ) = _extract_compact_usage_fields(compact_response)
1234
+ await self._write_request_log(
1235
+ account_id=account.id,
1236
+ request_id=request_id,
1237
+ model=run_model,
1238
+ reasoning_effort=wire_reasoning_effort,
1239
+ latency_ms=latency_ms,
1240
+ status="success",
1241
+ input_tokens=input_tokens,
1242
+ output_tokens=output_tokens,
1243
+ cached_input_tokens=cached_input_tokens,
1244
+ reasoning_tokens=reasoning_tokens,
1245
+ service_tier=service_tier,
1246
+ )
1247
+ completed = await self._repository.complete_run(
1248
+ run.id,
1249
+ status=AUTOMATION_RUN_STATUS_PARTIAL if attempt_count > 1 else AUTOMATION_RUN_STATUS_SUCCESS,
1250
+ finished_at=utcnow(),
1251
+ account_id=account.id,
1252
+ error_code=None,
1253
+ error_message=None,
1254
+ attempt_count=attempt_count,
1255
+ )
1256
+ return self._to_run_data(completed)
1257
+ except RefreshError as exc:
1258
+ last_error_code = exc.code or "authentication_error"
1259
+ last_error_message = exc.message
1260
+ if self._is_retryable_account_failure(last_error_code):
1261
+ continue
1262
+ break
1263
+ except ProxyResponseError as exc:
1264
+ error_code, error_message = _extract_proxy_error(exc)
1265
+ last_error_code = error_code
1266
+ last_error_message = error_message
1267
+ await self._write_request_log(
1268
+ account_id=account.id,
1269
+ request_id=_automation_request_id(None, run.id, attempt_count),
1270
+ model=run_model,
1271
+ reasoning_effort=wire_reasoning_effort,
1272
+ latency_ms=_elapsed_ms(request_started_at),
1273
+ status="error",
1274
+ error_code=error_code,
1275
+ error_message=error_message,
1276
+ )
1277
+ await self._mark_permanent_account_failure(account, error_code)
1278
+ if self._is_retryable_account_failure(error_code):
1279
+ continue
1280
+ break
1281
+ except Exception as exc:
1282
+ last_error_code = "automation_ping_failed"
1283
+ last_error_message = str(exc) or "Automation ping failed"
1284
+ if request_started_at is not None:
1285
+ await self._write_request_log(
1286
+ account_id=account.id,
1287
+ request_id=_automation_request_id(None, run.id, attempt_count),
1288
+ model=run_model,
1289
+ reasoning_effort=wire_reasoning_effort,
1290
+ latency_ms=_elapsed_ms(request_started_at),
1291
+ status="error",
1292
+ error_code=last_error_code,
1293
+ error_message=last_error_message,
1294
+ )
1295
+ continue
1296
+
1297
+ completed = await self._repository.complete_run(
1298
+ run.id,
1299
+ status=AUTOMATION_RUN_STATUS_FAILED,
1300
+ finished_at=utcnow(),
1301
+ account_id=last_attempted_account_id,
1302
+ error_code=last_error_code,
1303
+ error_message=last_error_message,
1304
+ attempt_count=attempt_count,
1305
+ )
1306
+ return self._to_run_data(completed)
1307
+
1308
+ async def _enrich_runs_with_progress(
1309
+ self,
1310
+ runs: list[AutomationRunRecord],
1311
+ *,
1312
+ apply_cycle_terminal_overrides: bool = False,
1313
+ ) -> list[AutomationRunData]:
1314
+ if not runs:
1315
+ return []
1316
+ jobs_by_id = await self._repository.get_jobs_by_ids([run.job_id for run in runs])
1317
+ cycle_cache: dict[str, _AutomationRunCycleSummary] = {}
1318
+ items: list[AutomationRunData] = []
1319
+ now = utcnow()
1320
+ for run in runs:
1321
+ job = jobs_by_id.get(run.job_id)
1322
+ summary = None
1323
+ if job is not None:
1324
+ summary = await self._build_cycle_summary_for_run(
1325
+ run=run,
1326
+ job=job,
1327
+ now_utc=now,
1328
+ cycle_cache=cycle_cache,
1329
+ )
1330
+ items.append(
1331
+ self._to_run_data(
1332
+ run,
1333
+ summary=summary,
1334
+ apply_cycle_terminal_overrides=apply_cycle_terminal_overrides,
1335
+ )
1336
+ )
1337
+ return items
1338
+
1339
+ async def _build_cycle_summary_for_run(
1340
+ self,
1341
+ *,
1342
+ run: AutomationRunRecord,
1343
+ job: AutomationJobRecord,
1344
+ now_utc: datetime,
1345
+ cycle_cache: dict[str, _AutomationRunCycleSummary] | None = None,
1346
+ ) -> _AutomationRunCycleSummary:
1347
+ if run.trigger == AUTOMATION_RUN_TRIGGER_SCHEDULED:
1348
+ return await self._build_scheduled_cycle_summary(
1349
+ run=run,
1350
+ job=job,
1351
+ now_utc=now_utc,
1352
+ cycle_cache=cycle_cache,
1353
+ )
1354
+ return await self._build_manual_cycle_summary(
1355
+ run=run,
1356
+ job=job,
1357
+ now_utc=now_utc,
1358
+ cycle_cache=cycle_cache,
1359
+ )
1360
+
1361
+ async def _build_manual_cycle_summary(
1362
+ self,
1363
+ *,
1364
+ run: AutomationRunRecord,
1365
+ job: AutomationJobRecord,
1366
+ now_utc: datetime,
1367
+ cycle_cache: dict[str, _AutomationRunCycleSummary] | None = None,
1368
+ ) -> _AutomationRunCycleSummary:
1369
+ parsed = _parse_manual_cycle_key(run.slot_key)
1370
+ cycle_key = _normalize_legacy_manual_cycle_key(run.cycle_key)
1371
+ if parsed is not None:
1372
+ cycle_id, _slot_key_prefix = parsed
1373
+ cycle_key = _manual_cycle_key(job.id, cycle_id)
1374
+ if cycle_key is None:
1375
+ return _AutomationRunCycleSummary(
1376
+ cycle_key=f"manual:{run.id}",
1377
+ cycle_started_at=run.scheduled_for,
1378
+ cycle_finished_at=run.finished_at,
1379
+ effective_status=run.status,
1380
+ total_accounts=1 if run.account_id else 0,
1381
+ completed_accounts=1 if run.account_id else 0,
1382
+ pending_accounts=0,
1383
+ error_code=run.error_code,
1384
+ error_message=run.error_message,
1385
+ accounts=[
1386
+ AutomationRunAccountStateData(
1387
+ account_id=run.account_id,
1388
+ status=run.status,
1389
+ run_id=run.id,
1390
+ scheduled_for=run.scheduled_for,
1391
+ started_at=run.started_at,
1392
+ finished_at=run.finished_at,
1393
+ error_code=run.error_code,
1394
+ error_message=run.error_message,
1395
+ )
1396
+ ]
1397
+ if run.account_id
1398
+ else [],
1399
+ )
1400
+
1401
+ if cycle_cache is not None and cycle_key in cycle_cache:
1402
+ return cycle_cache[cycle_key]
1403
+
1404
+ cycle_runs = await self._repository.list_runs_for_cycle_key(cycle_key=cycle_key)
1405
+ if run.account_id and run.account_id not in {cycle_run.account_id for cycle_run in cycle_runs}:
1406
+ normalized_run_cycle_key = _normalize_legacy_manual_cycle_key(run.cycle_key)
1407
+ if normalized_run_cycle_key == cycle_key:
1408
+ cycle_runs = [run, *cycle_runs]
1409
+ cycle = await self._repository.get_run_cycle(cycle_key=cycle_key)
1410
+
1411
+ if cycle is not None:
1412
+ expected_account_ids = [entry.account_id for entry in cycle.accounts]
1413
+ scheduled_for_by_account_id = {entry.account_id: entry.scheduled_for for entry in cycle.accounts}
1414
+ else:
1415
+ expected_account_ids = []
1416
+ seen_account_ids: set[str] = set()
1417
+ for cycle_run in sorted(
1418
+ cycle_runs,
1419
+ key=lambda entry: (entry.scheduled_for, entry.account_id or "", entry.id),
1420
+ ):
1421
+ account_id = cycle_run.account_id
1422
+ if not account_id:
1423
+ continue
1424
+ if account_id in seen_account_ids:
1425
+ continue
1426
+ seen_account_ids.add(account_id)
1427
+ expected_account_ids.append(account_id)
1428
+
1429
+ if not expected_account_ids and run.account_id:
1430
+ expected_account_ids = [run.account_id]
1431
+ scheduled_for_by_account_id = {
1432
+ entry.account_id: entry.scheduled_for
1433
+ for entry in cycle_runs
1434
+ if entry.account_id in expected_account_ids
1435
+ }
1436
+ latest_run_by_account_id: dict[str, AutomationRunRecord] = {}
1437
+ for cycle_run in cycle_runs:
1438
+ account_id = self._resolve_manual_cycle_run_account_id(
1439
+ cycle_run,
1440
+ job=job,
1441
+ cycle_key=cycle_key,
1442
+ expected_account_ids=expected_account_ids,
1443
+ )
1444
+ if account_id is None or account_id in latest_run_by_account_id:
1445
+ continue
1446
+ latest_run_by_account_id[account_id] = cycle_run
1447
+ cycle_started_at = min(
1448
+ scheduled_for_by_account_id.values(),
1449
+ default=min((entry.scheduled_for for entry in cycle_runs), default=run.scheduled_for),
1450
+ )
1451
+ expected_set = set(expected_account_ids)
1452
+ observed_only = [account_id for account_id in latest_run_by_account_id if account_id not in expected_set]
1453
+ include_paused_accounts = cycle.include_paused_accounts if cycle is not None else job.include_paused_accounts
1454
+ eligible_pending_account_ids = await self._resolve_eligible_account_ids(
1455
+ expected_account_ids,
1456
+ include_paused_accounts=include_paused_accounts,
1457
+ now_utc=now_utc,
1458
+ )
1459
+ all_account_ids = [
1460
+ *[
1461
+ account_id
1462
+ for account_id in expected_account_ids
1463
+ if self._should_include_manual_cycle_account(
1464
+ account_id,
1465
+ latest_run_by_account_id=latest_run_by_account_id,
1466
+ eligible_pending_account_ids=eligible_pending_account_ids,
1467
+ )
1468
+ ],
1469
+ *observed_only,
1470
+ ]
1471
+
1472
+ account_states: list[AutomationRunAccountStateData] = []
1473
+ for account_id in all_account_ids:
1474
+ observed_run = latest_run_by_account_id.get(account_id)
1475
+ if observed_run is None:
1476
+ account_states.append(
1477
+ AutomationRunAccountStateData(
1478
+ account_id=account_id,
1479
+ status="pending",
1480
+ run_id=None,
1481
+ scheduled_for=scheduled_for_by_account_id.get(account_id),
1482
+ started_at=None,
1483
+ finished_at=None,
1484
+ error_code=None,
1485
+ error_message=None,
1486
+ )
1487
+ )
1488
+ continue
1489
+ account_status = observed_run.status
1490
+ if observed_run.status == AUTOMATION_RUN_STATUS_RUNNING and observed_run.scheduled_for > now_utc:
1491
+ account_status = "pending"
1492
+ account_states.append(
1493
+ AutomationRunAccountStateData(
1494
+ account_id=account_id,
1495
+ status=account_status,
1496
+ run_id=observed_run.id,
1497
+ scheduled_for=observed_run.scheduled_for,
1498
+ started_at=observed_run.started_at,
1499
+ finished_at=observed_run.finished_at,
1500
+ error_code=observed_run.error_code,
1501
+ error_message=observed_run.error_message,
1502
+ )
1503
+ )
1504
+
1505
+ expected_accounts_hint = (
1506
+ len(all_account_ids)
1507
+ if cycle is not None
1508
+ else max(
1509
+ [entry.cycle_expected_accounts or 0 for entry in cycle_runs],
1510
+ default=run.cycle_expected_accounts or 0,
1511
+ )
1512
+ )
1513
+ total_accounts = max(len(all_account_ids), expected_accounts_hint)
1514
+ completed_accounts = sum(
1515
+ 1
1516
+ for account_id in all_account_ids
1517
+ if (entry := latest_run_by_account_id.get(account_id)) is not None
1518
+ and entry.status != AUTOMATION_RUN_STATUS_RUNNING
1519
+ )
1520
+ pending_accounts = max(0, total_accounts - completed_accounts)
1521
+ status_counts = {
1522
+ "success": 0,
1523
+ "failed": 0,
1524
+ "partial": 0,
1525
+ "running": 0,
1526
+ }
1527
+ for entry in account_states:
1528
+ if entry.status in status_counts:
1529
+ status_counts[entry.status] += 1
1530
+ effective_status = _resolve_effective_status(
1531
+ pending_accounts=pending_accounts,
1532
+ completed_accounts=completed_accounts,
1533
+ success_count=status_counts["success"],
1534
+ failed_count=status_counts["failed"],
1535
+ partial_count=status_counts["partial"],
1536
+ running_count=status_counts["running"],
1537
+ fallback_status=run.status,
1538
+ now_utc=now_utc,
1539
+ window_end_utc=(
1540
+ cycle.cycle_window_end
1541
+ if cycle is not None
1542
+ else max(
1543
+ [entry.cycle_window_end or entry.scheduled_for for entry in cycle_runs],
1544
+ default=run.cycle_window_end or run.scheduled_for,
1545
+ )
1546
+ )
1547
+ or run.scheduled_for,
1548
+ )
1549
+ cycle_finished_at = _resolve_cycle_finished_at(account_states, pending_accounts=pending_accounts)
1550
+ error_code, error_message = _resolve_cycle_error_summary(account_states)
1551
+ summary = _AutomationRunCycleSummary(
1552
+ cycle_key=cycle_key,
1553
+ cycle_started_at=cycle_started_at,
1554
+ cycle_finished_at=cycle_finished_at,
1555
+ effective_status=effective_status,
1556
+ total_accounts=total_accounts,
1557
+ completed_accounts=completed_accounts,
1558
+ pending_accounts=pending_accounts,
1559
+ error_code=error_code,
1560
+ error_message=error_message,
1561
+ accounts=account_states,
1562
+ )
1563
+ if cycle_cache is not None:
1564
+ cycle_cache[cycle_key] = summary
1565
+ return summary
1566
+
1567
+ async def _build_scheduled_cycle_summary(
1568
+ self,
1569
+ *,
1570
+ run: AutomationRunRecord,
1571
+ job: AutomationJobRecord,
1572
+ now_utc: datetime,
1573
+ cycle_cache: dict[str, _AutomationRunCycleSummary] | None = None,
1574
+ ) -> _AutomationRunCycleSummary:
1575
+ fallback_due_slot = compute_latest_due_slot_utc(
1576
+ run.scheduled_for,
1577
+ schedule_time=job.schedule_time,
1578
+ timezone_name=job.schedule_timezone,
1579
+ schedule_days=job.schedule_days,
1580
+ )
1581
+ cycle_key = (
1582
+ run.cycle_key.strip()
1583
+ if run.cycle_key and run.cycle_key.strip()
1584
+ else _scheduled_cycle_key(job.id, fallback_due_slot)
1585
+ )
1586
+ due_slot = _parse_scheduled_cycle_due_slot(cycle_key, job_id=job.id) or fallback_due_slot
1587
+ if cycle_cache is not None and cycle_key in cycle_cache:
1588
+ return cycle_cache[cycle_key]
1589
+
1590
+ cycle = await self._repository.get_run_cycle(cycle_key=cycle_key)
1591
+ cycle_runs = await self._repository.list_runs_for_cycle_key(cycle_key=cycle_key)
1592
+
1593
+ if cycle is not None:
1594
+ expected_account_ids = [entry.account_id for entry in cycle.accounts]
1595
+ scheduled_for_by_account_id = {entry.account_id: entry.scheduled_for for entry in cycle.accounts}
1596
+ else:
1597
+ expected_account_ids = []
1598
+ scheduled_for_by_account_id = {}
1599
+ for cycle_run in sorted(
1600
+ (entry for entry in cycle_runs if entry.account_id),
1601
+ key=lambda entry: (entry.scheduled_for, entry.account_id or "", entry.id),
1602
+ ):
1603
+ account_id = cycle_run.account_id
1604
+ if account_id is None or account_id in scheduled_for_by_account_id:
1605
+ continue
1606
+ scheduled_for_by_account_id[account_id] = cycle_run.scheduled_for
1607
+ expected_account_ids.append(account_id)
1608
+ if not expected_account_ids and run.account_id:
1609
+ expected_account_ids = [run.account_id]
1610
+ scheduled_for_by_account_id[run.account_id] = run.scheduled_for
1611
+ latest_run_by_account_id: dict[str, AutomationRunRecord] = {}
1612
+ for cycle_run in cycle_runs:
1613
+ account_id = self._resolve_scheduled_cycle_run_account_id(
1614
+ cycle_run,
1615
+ job=job,
1616
+ due_slot=due_slot,
1617
+ expected_account_ids=expected_account_ids,
1618
+ )
1619
+ if account_id is None or account_id in latest_run_by_account_id:
1620
+ continue
1621
+ latest_run_by_account_id[account_id] = cycle_run
1622
+ observed_account_ids = [
1623
+ account_id for account_id in latest_run_by_account_id if account_id not in expected_account_ids
1624
+ ]
1625
+ include_paused_accounts = cycle.include_paused_accounts if cycle is not None else job.include_paused_accounts
1626
+ eligible_pending_account_ids = await self._resolve_eligible_account_ids(
1627
+ expected_account_ids,
1628
+ include_paused_accounts=include_paused_accounts,
1629
+ now_utc=now_utc,
1630
+ )
1631
+ all_account_ids = [
1632
+ *[
1633
+ account_id
1634
+ for account_id in expected_account_ids
1635
+ if account_id in latest_run_by_account_id or account_id in eligible_pending_account_ids
1636
+ ],
1637
+ *observed_account_ids,
1638
+ ]
1639
+
1640
+ account_states: list[AutomationRunAccountStateData] = []
1641
+ for account_id in all_account_ids:
1642
+ observed_run = latest_run_by_account_id.get(account_id)
1643
+ if observed_run is None:
1644
+ account_states.append(
1645
+ AutomationRunAccountStateData(
1646
+ account_id=account_id,
1647
+ status="pending",
1648
+ run_id=None,
1649
+ scheduled_for=scheduled_for_by_account_id.get(account_id),
1650
+ started_at=None,
1651
+ finished_at=None,
1652
+ error_code=None,
1653
+ error_message=None,
1654
+ )
1655
+ )
1656
+ continue
1657
+ account_states.append(
1658
+ AutomationRunAccountStateData(
1659
+ account_id=account_id,
1660
+ status=observed_run.status,
1661
+ run_id=observed_run.id,
1662
+ scheduled_for=observed_run.scheduled_for,
1663
+ started_at=observed_run.started_at,
1664
+ finished_at=observed_run.finished_at,
1665
+ error_code=observed_run.error_code,
1666
+ error_message=observed_run.error_message,
1667
+ )
1668
+ )
1669
+
1670
+ expected_accounts_hint = (
1671
+ len(all_account_ids)
1672
+ if cycle is not None
1673
+ else max(
1674
+ [entry.cycle_expected_accounts or 0 for entry in cycle_runs],
1675
+ default=run.cycle_expected_accounts or 0,
1676
+ )
1677
+ )
1678
+ total_accounts = max(len(all_account_ids), expected_accounts_hint)
1679
+ completed_accounts = sum(
1680
+ 1
1681
+ for account_id in all_account_ids
1682
+ if (entry := latest_run_by_account_id.get(account_id)) is not None
1683
+ and entry.status != AUTOMATION_RUN_STATUS_RUNNING
1684
+ )
1685
+ pending_accounts = max(0, total_accounts - completed_accounts)
1686
+ status_counts = {
1687
+ "success": 0,
1688
+ "failed": 0,
1689
+ "partial": 0,
1690
+ "running": 0,
1691
+ }
1692
+ for entry in latest_run_by_account_id.values():
1693
+ if entry.status in status_counts:
1694
+ status_counts[entry.status] += 1
1695
+ window_end = (
1696
+ cycle.cycle_window_end
1697
+ if cycle is not None
1698
+ else max(
1699
+ [entry.cycle_window_end or entry.scheduled_for for entry in cycle_runs],
1700
+ default=run.cycle_window_end or run.scheduled_for,
1701
+ )
1702
+ ) or run.scheduled_for
1703
+ cycle_started_at = min(
1704
+ scheduled_for_by_account_id.values(),
1705
+ default=min((entry.scheduled_for for entry in cycle_runs), default=run.scheduled_for),
1706
+ )
1707
+ effective_status = _resolve_effective_status(
1708
+ pending_accounts=pending_accounts,
1709
+ completed_accounts=completed_accounts,
1710
+ success_count=status_counts["success"],
1711
+ failed_count=status_counts["failed"],
1712
+ partial_count=status_counts["partial"],
1713
+ running_count=status_counts["running"],
1714
+ fallback_status=run.status,
1715
+ now_utc=now_utc,
1716
+ window_end_utc=window_end,
1717
+ )
1718
+ cycle_finished_at = _resolve_cycle_finished_at(account_states, pending_accounts=pending_accounts)
1719
+ error_code, error_message = _resolve_cycle_error_summary(account_states)
1720
+ summary = _AutomationRunCycleSummary(
1721
+ cycle_key=cycle_key,
1722
+ cycle_started_at=cycle_started_at,
1723
+ cycle_finished_at=cycle_finished_at,
1724
+ effective_status=effective_status,
1725
+ total_accounts=total_accounts,
1726
+ completed_accounts=completed_accounts,
1727
+ pending_accounts=pending_accounts,
1728
+ error_code=error_code,
1729
+ error_message=error_message,
1730
+ accounts=account_states,
1731
+ )
1732
+ if cycle_cache is not None:
1733
+ cycle_cache[cycle_key] = summary
1734
+ return summary
1735
+
1736
+ @staticmethod
1737
+ def _resolve_manual_cycle_run_account_id(
1738
+ run: AutomationRunRecord,
1739
+ *,
1740
+ job: AutomationJobRecord,
1741
+ cycle_key: str,
1742
+ expected_account_ids: list[str],
1743
+ ) -> str | None:
1744
+ parts = cycle_key.split(":")
1745
+ if len(parts) != 3 or parts[0] != "manual" or not parts[2]:
1746
+ return run.account_id
1747
+ cycle_id = parts[2]
1748
+ for account_id in expected_account_ids:
1749
+ if run.slot_key == _manual_slot_key(job.id, cycle_id, account_id):
1750
+ return account_id
1751
+ return run.account_id
1752
+
1753
+ @staticmethod
1754
+ def _resolve_scheduled_cycle_run_account_id(
1755
+ run: AutomationRunRecord,
1756
+ *,
1757
+ job: AutomationJobRecord,
1758
+ due_slot: datetime,
1759
+ expected_account_ids: list[str],
1760
+ ) -> str | None:
1761
+ for account_id in expected_account_ids:
1762
+ if run.slot_key == _scheduled_slot_key(job.id, account_id=account_id, due_slot=due_slot):
1763
+ return account_id
1764
+ return run.account_id
1765
+
1766
+ @staticmethod
1767
+ def _should_include_manual_cycle_account(
1768
+ account_id: str,
1769
+ *,
1770
+ latest_run_by_account_id: dict[str, AutomationRunRecord],
1771
+ eligible_pending_account_ids: set[str],
1772
+ ) -> bool:
1773
+ observed_run = latest_run_by_account_id.get(account_id)
1774
+ if observed_run is None:
1775
+ return account_id in eligible_pending_account_ids
1776
+ if _is_unclaimed_run_placeholder(observed_run):
1777
+ return account_id in eligible_pending_account_ids
1778
+ return True
1779
+
1780
+ async def _resolve_eligible_account_ids(
1781
+ self,
1782
+ account_ids: list[str],
1783
+ *,
1784
+ include_paused_accounts: bool,
1785
+ now_utc: datetime | None = None,
1786
+ ) -> set[str]:
1787
+ if not account_ids:
1788
+ return set()
1789
+ accounts = await self._accounts_repository.list_accounts()
1790
+ await self._reactivate_accounts_if_reset_elapsed(accounts, now_utc=now_utc or utcnow())
1791
+ accounts_by_id = {account.id: account for account in accounts}
1792
+ return {
1793
+ account_id
1794
+ for account_id in account_ids
1795
+ if (account := accounts_by_id.get(account_id)) is not None
1796
+ and self._is_account_eligible_for_automation(
1797
+ account,
1798
+ include_paused_accounts=include_paused_accounts,
1799
+ )
1800
+ }
1801
+
1802
+ async def _normalize_create_input(self, payload: AutomationJobCreateInput) -> AutomationJobCreateInput:
1803
+ name = _normalize_non_empty(payload.name, field_label="name", code="invalid_name")
1804
+ model = _normalize_chatgpt_model(payload.model)
1805
+ schedule_type = _normalize_schedule_type(payload.schedule_type)
1806
+ schedule_time = normalize_schedule_time(payload.schedule_time)
1807
+ schedule_timezone = validate_timezone(payload.schedule_timezone)
1808
+ schedule_days = normalize_schedule_days(payload.schedule_days)
1809
+ schedule_threshold_minutes = normalize_schedule_threshold_minutes(payload.schedule_threshold_minutes)
1810
+ reasoning_effort = _normalize_reasoning_effort(payload.reasoning_effort, model_slug=model)
1811
+ prompt = _normalize_prompt(payload.prompt)
1812
+ account_ids = await self._normalize_account_ids(
1813
+ payload.account_ids,
1814
+ include_paused_accounts=payload.include_paused_accounts,
1815
+ )
1816
+ return AutomationJobCreateInput(
1817
+ name=name,
1818
+ enabled=payload.enabled,
1819
+ include_paused_accounts=payload.include_paused_accounts,
1820
+ schedule_type=schedule_type,
1821
+ schedule_time=schedule_time,
1822
+ schedule_timezone=schedule_timezone,
1823
+ schedule_days=schedule_days,
1824
+ schedule_threshold_minutes=schedule_threshold_minutes,
1825
+ model=model,
1826
+ reasoning_effort=reasoning_effort,
1827
+ prompt=prompt,
1828
+ account_ids=account_ids,
1829
+ )
1830
+
1831
+ async def _normalize_update_input(
1832
+ self,
1833
+ payload: AutomationJobUpdateInput,
1834
+ *,
1835
+ existing: AutomationJobRecord,
1836
+ ) -> AutomationJobUpdateInput:
1837
+ if payload.name is None:
1838
+ name = None
1839
+ else:
1840
+ name = _normalize_non_empty(payload.name, field_label="name", code="invalid_name")
1841
+ if payload.model is None:
1842
+ model = None
1843
+ else:
1844
+ model = _normalize_chatgpt_model(payload.model)
1845
+ model_for_reasoning = model if model is not None else existing.model
1846
+ if payload.reasoning_effort_set:
1847
+ reasoning_effort = _normalize_reasoning_effort(payload.reasoning_effort, model_slug=model_for_reasoning)
1848
+ elif model is not None and existing.reasoning_effort is not None:
1849
+ _normalize_reasoning_effort(existing.reasoning_effort, model_slug=model_for_reasoning)
1850
+ reasoning_effort = None
1851
+ else:
1852
+ reasoning_effort = None
1853
+ if payload.schedule_type is None:
1854
+ schedule_type = None
1855
+ else:
1856
+ schedule_type = _normalize_schedule_type(payload.schedule_type)
1857
+ if payload.schedule_time is None:
1858
+ schedule_time = None
1859
+ else:
1860
+ schedule_time = normalize_schedule_time(payload.schedule_time)
1861
+ if payload.schedule_timezone is None:
1862
+ schedule_timezone = None
1863
+ else:
1864
+ schedule_timezone = validate_timezone(payload.schedule_timezone)
1865
+ if payload.schedule_days is None:
1866
+ schedule_days = None
1867
+ else:
1868
+ schedule_days = normalize_schedule_days(payload.schedule_days)
1869
+ if payload.schedule_threshold_minutes is None:
1870
+ schedule_threshold_minutes = None
1871
+ else:
1872
+ schedule_threshold_minutes = normalize_schedule_threshold_minutes(payload.schedule_threshold_minutes)
1873
+ if payload.prompt is None:
1874
+ prompt = None
1875
+ else:
1876
+ prompt = _normalize_prompt(payload.prompt)
1877
+ if payload.include_paused_accounts is None:
1878
+ include_paused_accounts = None
1879
+ else:
1880
+ include_paused_accounts = payload.include_paused_accounts
1881
+ if payload.account_ids is None:
1882
+ account_ids = None
1883
+ else:
1884
+ account_ids = await self._normalize_account_ids(
1885
+ payload.account_ids,
1886
+ include_paused_accounts=(
1887
+ payload.include_paused_accounts
1888
+ if payload.include_paused_accounts is not None
1889
+ else existing.include_paused_accounts
1890
+ ),
1891
+ )
1892
+
1893
+ return AutomationJobUpdateInput(
1894
+ name=name,
1895
+ enabled=payload.enabled,
1896
+ include_paused_accounts=include_paused_accounts,
1897
+ schedule_type=schedule_type,
1898
+ schedule_time=schedule_time,
1899
+ schedule_timezone=schedule_timezone,
1900
+ schedule_days=schedule_days,
1901
+ schedule_threshold_minutes=schedule_threshold_minutes,
1902
+ model=model,
1903
+ reasoning_effort=reasoning_effort,
1904
+ reasoning_effort_set=payload.reasoning_effort_set,
1905
+ prompt=prompt,
1906
+ account_ids=account_ids,
1907
+ )
1908
+
1909
+ async def _normalize_account_ids(
1910
+ self,
1911
+ account_ids: list[str],
1912
+ *,
1913
+ include_paused_accounts: bool = False,
1914
+ ) -> list[str]:
1915
+ normalized = [account_id.strip() for account_id in account_ids if account_id.strip()]
1916
+ if not normalized:
1917
+ accounts = await self._accounts_repository.list_accounts()
1918
+ await self._reactivate_accounts_if_reset_elapsed(accounts, now_utc=utcnow())
1919
+ has_eligible_accounts = any(
1920
+ self._is_account_eligible_for_automation(
1921
+ account,
1922
+ include_paused_accounts=include_paused_accounts,
1923
+ )
1924
+ for account in accounts
1925
+ )
1926
+ if not has_eligible_accounts:
1927
+ raise AutomationValidationError(
1928
+ "No eligible accounts available for this automation",
1929
+ code="invalid_account_ids",
1930
+ )
1931
+ return []
1932
+ deduped = list(dict.fromkeys(normalized))
1933
+ existing = await self._repository.list_existing_account_ids(deduped)
1934
+ missing = [account_id for account_id in deduped if account_id not in existing]
1935
+ if missing:
1936
+ raise AutomationValidationError(
1937
+ f"Unknown account IDs: {', '.join(missing)}",
1938
+ code="invalid_account_ids",
1939
+ )
1940
+ return deduped
1941
+
1942
+ def _to_job_data(
1943
+ self,
1944
+ job: AutomationJobRecord,
1945
+ last_run: AutomationRunData | None,
1946
+ *,
1947
+ now_utc: datetime,
1948
+ ) -> AutomationJobData:
1949
+ next_run_at = None
1950
+ if job.enabled:
1951
+ next_run_at = compute_next_run_utc(
1952
+ now_utc,
1953
+ schedule_time=job.schedule_time,
1954
+ timezone_name=job.schedule_timezone,
1955
+ schedule_days=job.schedule_days,
1956
+ )
1957
+ return AutomationJobData(
1958
+ id=job.id,
1959
+ name=job.name,
1960
+ enabled=job.enabled,
1961
+ include_paused_accounts=job.include_paused_accounts,
1962
+ account_scope_all=job.account_scope_all,
1963
+ schedule=AutomationScheduleData(
1964
+ type=job.schedule_type,
1965
+ time=job.schedule_time,
1966
+ timezone=job.schedule_timezone,
1967
+ days=job.schedule_days,
1968
+ threshold_minutes=job.schedule_threshold_minutes,
1969
+ ),
1970
+ model=job.model,
1971
+ reasoning_effort=job.reasoning_effort,
1972
+ prompt=job.prompt,
1973
+ account_ids=job.account_ids,
1974
+ next_run_at=next_run_at,
1975
+ last_run=last_run,
1976
+ )
1977
+
1978
+ async def _resolve_job_account_ids_for_dispatch(self, job: AutomationJobRecord) -> list[str]:
1979
+ now_utc = utcnow()
1980
+ account_ids = list(job.account_ids)
1981
+ accounts = await self._accounts_repository.list_accounts()
1982
+ await self._reactivate_accounts_if_reset_elapsed(accounts, now_utc=now_utc)
1983
+ accounts_by_id = {account.id: account for account in accounts}
1984
+ candidate_accounts: list[Account]
1985
+ if job.account_scope_all:
1986
+ candidate_accounts = accounts
1987
+ else:
1988
+ candidate_accounts = [
1989
+ accounts_by_id[account_id] for account_id in account_ids if account_id in accounts_by_id
1990
+ ]
1991
+ return [
1992
+ account.id
1993
+ for account in candidate_accounts
1994
+ if self._is_account_eligible_for_automation(
1995
+ account,
1996
+ include_paused_accounts=job.include_paused_accounts,
1997
+ )
1998
+ ]
1999
+
2000
+ async def _reactivate_accounts_if_reset_elapsed(
2001
+ self,
2002
+ accounts: list[Account],
2003
+ *,
2004
+ now_utc: datetime,
2005
+ ) -> None:
2006
+ now_epoch = naive_utc_to_epoch(now_utc)
2007
+ for account in accounts:
2008
+ if account.status not in {AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED}:
2009
+ continue
2010
+ if account.reset_at is None or account.reset_at > now_epoch:
2011
+ continue
2012
+ updated = await self._accounts_repository.update_status_if_current(
2013
+ account.id,
2014
+ AccountStatus.ACTIVE,
2015
+ None,
2016
+ None,
2017
+ blocked_at=None,
2018
+ expected_status=account.status,
2019
+ expected_deactivation_reason=account.deactivation_reason,
2020
+ expected_reset_at=account.reset_at,
2021
+ expected_blocked_at=account.blocked_at,
2022
+ )
2023
+ if updated:
2024
+ account.status = AccountStatus.ACTIVE
2025
+ account.deactivation_reason = None
2026
+ account.reset_at = None
2027
+ account.blocked_at = None
2028
+
2029
+ @staticmethod
2030
+ def _to_run_data(
2031
+ run: AutomationRunRecord,
2032
+ *,
2033
+ summary: _AutomationRunCycleSummary | None = None,
2034
+ apply_cycle_terminal_overrides: bool = False,
2035
+ ) -> AutomationRunData:
2036
+ cycle_started_at = summary.cycle_started_at if summary is not None else None
2037
+ cycle_finished_at = (
2038
+ summary.cycle_finished_at
2039
+ if summary is not None and apply_cycle_terminal_overrides and summary.cycle_finished_at is not None
2040
+ else run.finished_at
2041
+ )
2042
+ error_code = (
2043
+ summary.error_code or run.error_code
2044
+ if summary is not None and apply_cycle_terminal_overrides
2045
+ else run.error_code
2046
+ )
2047
+ error_message = (
2048
+ summary.error_message or run.error_message
2049
+ if summary is not None and apply_cycle_terminal_overrides
2050
+ else run.error_message
2051
+ )
2052
+ scheduled_for = cycle_started_at or run.scheduled_for
2053
+ started_at = cycle_started_at or run.started_at
2054
+ return AutomationRunData(
2055
+ id=run.id,
2056
+ job_id=run.job_id,
2057
+ job_name=run.job_name,
2058
+ model=run.model,
2059
+ reasoning_effort=run.reasoning_effort,
2060
+ trigger=run.trigger,
2061
+ status=run.status,
2062
+ scheduled_for=scheduled_for,
2063
+ started_at=started_at,
2064
+ finished_at=cycle_finished_at if apply_cycle_terminal_overrides else run.finished_at,
2065
+ account_id=run.account_id,
2066
+ error_code=error_code,
2067
+ error_message=error_message,
2068
+ attempt_count=run.attempt_count,
2069
+ effective_status=summary.effective_status if summary is not None else None,
2070
+ total_accounts=summary.total_accounts if summary is not None else None,
2071
+ completed_accounts=summary.completed_accounts if summary is not None else None,
2072
+ pending_accounts=summary.pending_accounts if summary is not None else None,
2073
+ cycle_key=summary.cycle_key if summary is not None else None,
2074
+ )
2075
+
2076
+ @staticmethod
2077
+ def _is_retryable_account_failure(error_code: str | None) -> bool:
2078
+ if not error_code:
2079
+ return False
2080
+ return error_code.lower() in _RETRYABLE_ACCOUNT_FAILURE_CODES
2081
+
2082
+ @staticmethod
2083
+ def _is_account_eligible_for_automation(
2084
+ account: Account,
2085
+ *,
2086
+ include_paused_accounts: bool,
2087
+ ) -> bool:
2088
+ if account.status in _AUTOMATION_ALWAYS_SKIPPED_ACCOUNT_STATUSES:
2089
+ return False
2090
+ if account.status == AccountStatus.PAUSED and not include_paused_accounts:
2091
+ return False
2092
+ return True
2093
+
2094
+ async def _write_request_log(
2095
+ self,
2096
+ *,
2097
+ account_id: str | None,
2098
+ request_id: str,
2099
+ model: str,
2100
+ reasoning_effort: str | None = None,
2101
+ latency_ms: int | None,
2102
+ status: str,
2103
+ error_code: str | None = None,
2104
+ error_message: str | None = None,
2105
+ input_tokens: int | None = None,
2106
+ output_tokens: int | None = None,
2107
+ cached_input_tokens: int | None = None,
2108
+ reasoning_tokens: int | None = None,
2109
+ service_tier: str | None = None,
2110
+ ) -> None:
2111
+ if self._request_logs_repository is None:
2112
+ return
2113
+ try:
2114
+ await self._request_logs_repository.add_log(
2115
+ account_id=account_id,
2116
+ request_id=request_id,
2117
+ model=model,
2118
+ input_tokens=input_tokens,
2119
+ output_tokens=output_tokens,
2120
+ cached_input_tokens=cached_input_tokens,
2121
+ reasoning_tokens=reasoning_tokens,
2122
+ reasoning_effort=reasoning_effort,
2123
+ latency_ms=latency_ms,
2124
+ status=status,
2125
+ error_code=error_code,
2126
+ error_message=error_message,
2127
+ service_tier=service_tier,
2128
+ transport="automation",
2129
+ )
2130
+ except Exception:
2131
+ logger.warning(
2132
+ "Failed to persist automation request log account_id=%s request_id=%s",
2133
+ account_id,
2134
+ request_id,
2135
+ exc_info=True,
2136
+ )
2137
+
2138
+ async def _mark_permanent_account_failure(self, account: Account, error_code: str | None) -> None:
2139
+ if error_code not in PERMANENT_FAILURE_CODES:
2140
+ return
2141
+ status = account_status_for_permanent_failure(error_code)
2142
+ reason = PERMANENT_FAILURE_CODES[error_code]
2143
+ updated = await self._accounts_repository.update_status(account.id, status, reason)
2144
+ if not updated:
2145
+ return
2146
+ account.status = status
2147
+ account.deactivation_reason = reason
2148
+ account.reset_at = None
2149
+ account.blocked_at = None
2150
+ if status == AccountStatus.DEACTIVATED:
2151
+ mark_account_routing_unavailable(account.id)
2152
+ get_account_selection_cache().invalidate()
2153
+
2154
+
2155
+ def _normalize_schedule_type(value: str) -> str:
2156
+ normalized = value.strip().lower()
2157
+ if normalized != AUTOMATION_SCHEDULE_DAILY:
2158
+ raise AutomationValidationError(
2159
+ f"Unsupported schedule type: {value}",
2160
+ code="invalid_schedule_type",
2161
+ )
2162
+ return normalized
2163
+
2164
+
2165
+ def _normalize_non_empty(value: str, *, field_label: str, code: str) -> str:
2166
+ normalized = value.strip()
2167
+ if not normalized:
2168
+ raise AutomationValidationError(f"{field_label} is required", code=code)
2169
+ return normalized
2170
+
2171
+
2172
+ def _normalize_prompt(value: str | None) -> str:
2173
+ if value is None:
2174
+ return DEFAULT_AUTOMATION_PROMPT
2175
+ normalized = value.strip()
2176
+ if not normalized:
2177
+ raise AutomationValidationError("prompt cannot be empty", code="invalid_prompt")
2178
+ return normalized
2179
+
2180
+
2181
+ def _normalize_reasoning_effort(value: str | None, *, model_slug: str) -> str | None:
2182
+ if value is None:
2183
+ return None
2184
+ normalized = value.strip().lower()
2185
+ if not normalized:
2186
+ return None
2187
+ allowed = {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
2188
+ if normalized not in allowed:
2189
+ raise AutomationValidationError(
2190
+ f"Unsupported reasoning effort: {value}",
2191
+ code="invalid_reasoning_effort",
2192
+ )
2193
+
2194
+ registry = get_model_registry()
2195
+ model = registry.get_models_with_fallback().get(model_slug)
2196
+ if model is None:
2197
+ return normalized
2198
+ supported = {level.effort.strip().lower() for level in model.supported_reasoning_levels if level.effort.strip()}
2199
+ if normalized not in supported:
2200
+ raise AutomationValidationError(
2201
+ f"Reasoning effort '{normalized}' is not supported by model '{model_slug}'",
2202
+ code="invalid_reasoning_effort",
2203
+ )
2204
+ return normalized
2205
+
2206
+
2207
+ def _normalize_chatgpt_model(value: str) -> str:
2208
+ normalized = _normalize_non_empty(value, field_label="model", code="invalid_model")
2209
+ models = get_model_registry().get_models_with_fallback()
2210
+ if normalized not in models:
2211
+ raise AutomationValidationError(
2212
+ f"Automation model '{normalized}' is not available for ChatGPT account routing",
2213
+ code="invalid_model",
2214
+ )
2215
+ return normalized
2216
+
2217
+
2218
+ def _prioritize_forced_account(account_ids: list[str], forced_account_id: str | None) -> list[str]:
2219
+ if forced_account_id is None:
2220
+ return list(account_ids)
2221
+ return [forced_account_id, *(account_id for account_id in account_ids if account_id != forced_account_id)]
2222
+
2223
+
2224
+ def _build_dispatch_plan(
2225
+ *,
2226
+ job_id: str,
2227
+ due_slot: datetime,
2228
+ account_ids: list[str],
2229
+ threshold_minutes: int,
2230
+ ) -> list[tuple[str, datetime]]:
2231
+ deduped_account_ids = list(dict.fromkeys(account_ids))
2232
+ if not deduped_account_ids:
2233
+ return []
2234
+ offsets = _pick_dispatch_offsets_seconds(
2235
+ job_id=job_id,
2236
+ due_slot=due_slot,
2237
+ account_count=len(deduped_account_ids),
2238
+ threshold_minutes=threshold_minutes,
2239
+ )
2240
+ return [
2241
+ (account_id, due_slot + timedelta(seconds=offset_seconds))
2242
+ for account_id, offset_seconds in zip(deduped_account_ids, offsets, strict=False)
2243
+ ]
2244
+
2245
+
2246
+ def _pick_dispatch_offsets_seconds(
2247
+ *,
2248
+ job_id: str,
2249
+ due_slot: datetime,
2250
+ account_count: int,
2251
+ threshold_minutes: int,
2252
+ ) -> list[int]:
2253
+ if account_count <= 0:
2254
+ return []
2255
+ if threshold_minutes <= 0:
2256
+ return [0] * account_count
2257
+
2258
+ max_offset_seconds = threshold_minutes * 60
2259
+ available_non_zero_offsets = list(range(1, max_offset_seconds + 1))
2260
+ seed = f"{job_id}:{due_slot.isoformat()}"
2261
+ rng = random.Random(seed)
2262
+
2263
+ if account_count == 1:
2264
+ return [0]
2265
+
2266
+ requested_non_zero_offsets = account_count - 1
2267
+ if requested_non_zero_offsets <= len(available_non_zero_offsets):
2268
+ offsets = [0, *rng.sample(available_non_zero_offsets, requested_non_zero_offsets)]
2269
+ rng.shuffle(offsets)
2270
+ return offsets
2271
+
2272
+ offsets = [0, *available_non_zero_offsets]
2273
+ rng.shuffle(offsets)
2274
+ for _ in range(requested_non_zero_offsets - len(available_non_zero_offsets)):
2275
+ offsets.append(rng.choice(available_non_zero_offsets))
2276
+ rng.shuffle(offsets)
2277
+ return offsets
2278
+
2279
+
2280
+ def _scheduled_slot_key(
2281
+ job_id: str,
2282
+ *,
2283
+ account_id: str | None = None,
2284
+ due_slot: datetime | None = None,
2285
+ ) -> str:
2286
+ slot_anchor = due_slot
2287
+ if slot_anchor is None:
2288
+ raise ValueError("due_slot is required for scheduled slot keys")
2289
+ if account_id is None:
2290
+ return f"scheduled:{job_id}:{slot_anchor.isoformat()}Z"
2291
+ seed = f"{job_id}:{slot_anchor.isoformat()}:{account_id}"
2292
+ digest = sha1(seed.encode("utf-8")).hexdigest()[:20]
2293
+ return f"scheduled:{job_id}:{digest}"
2294
+
2295
+
2296
+ def _scheduled_cycle_key(job_id: str, due_slot: datetime) -> str:
2297
+ return f"scheduled:{job_id}:{due_slot.isoformat()}"
2298
+
2299
+
2300
+ def _parse_scheduled_cycle_due_slot(cycle_key: str, *, job_id: str) -> datetime | None:
2301
+ parts = cycle_key.split(":", maxsplit=2)
2302
+ if len(parts) != 3 or parts[0] != "scheduled" or parts[1] != job_id:
2303
+ return None
2304
+ try:
2305
+ return _normalize_now_utc(datetime.fromisoformat(parts[2].removesuffix("Z")))
2306
+ except ValueError:
2307
+ return None
2308
+
2309
+
2310
+ def _manual_cycle_key(job_id: str, cycle_id: str) -> str:
2311
+ return f"manual:{job_id}:{cycle_id}"
2312
+
2313
+
2314
+ def _normalize_legacy_manual_cycle_key(value: str | None) -> str | None:
2315
+ if value is None:
2316
+ return None
2317
+ stripped = value.strip()
2318
+ if not stripped:
2319
+ return None
2320
+ parts = stripped.split(":")
2321
+ if len(parts) == 3 and parts[0] == "manual" and parts[1] and parts[2]:
2322
+ return stripped
2323
+ if len(parts) == 4 and parts[0] == "manual" and parts[1] and parts[2]:
2324
+ return f"manual:{parts[1]}:{parts[2]}"
2325
+ return stripped
2326
+
2327
+
2328
+ def _manual_slot_key(job_id: str, cycle_id: str, account_id: str) -> str:
2329
+ seed = f"{job_id}:{cycle_id}:{account_id}"
2330
+ digest = sha1(seed.encode("utf-8")).hexdigest()[:20]
2331
+ return f"{_manual_cycle_key(job_id, cycle_id)}:{digest}"
2332
+
2333
+
2334
+ def _parse_manual_cycle_key(slot_key: str) -> tuple[str, str] | None:
2335
+ parts = slot_key.split(":")
2336
+ if len(parts) != 4:
2337
+ return None
2338
+ trigger, job_id, cycle_id, _digest = parts
2339
+ if trigger != "manual" or not job_id or not cycle_id:
2340
+ return None
2341
+ return cycle_id, f"manual:{job_id}:{cycle_id}:"
2342
+
2343
+
2344
+ def _is_unclaimed_run_placeholder(run: AutomationRunRecord) -> bool:
2345
+ return (
2346
+ run.status == AUTOMATION_RUN_STATUS_RUNNING
2347
+ and run.finished_at is None
2348
+ and run.attempt_count == 0
2349
+ and run.started_at <= run.scheduled_for
2350
+ )
2351
+
2352
+
2353
+ def _resolve_effective_status(
2354
+ *,
2355
+ pending_accounts: int,
2356
+ completed_accounts: int,
2357
+ success_count: int,
2358
+ failed_count: int,
2359
+ partial_count: int,
2360
+ running_count: int,
2361
+ fallback_status: str,
2362
+ now_utc: datetime,
2363
+ window_end_utc: datetime,
2364
+ ) -> str:
2365
+ if running_count > 0:
2366
+ return AUTOMATION_RUN_STATUS_RUNNING
2367
+ if pending_accounts > 0 and now_utc <= window_end_utc:
2368
+ return AUTOMATION_RUN_STATUS_RUNNING
2369
+ if pending_accounts > 0:
2370
+ return AUTOMATION_RUN_STATUS_PARTIAL if completed_accounts > 0 else AUTOMATION_RUN_STATUS_FAILED
2371
+ if success_count > 0 and failed_count == 0 and partial_count == 0:
2372
+ return AUTOMATION_RUN_STATUS_SUCCESS
2373
+ if success_count > 0 and (failed_count > 0 or partial_count > 0):
2374
+ return AUTOMATION_RUN_STATUS_PARTIAL
2375
+ if failed_count > 0 and success_count == 0 and partial_count == 0:
2376
+ return AUTOMATION_RUN_STATUS_FAILED
2377
+ if partial_count > 0:
2378
+ return AUTOMATION_RUN_STATUS_PARTIAL
2379
+ return fallback_status
2380
+
2381
+
2382
+ def _resolve_cycle_finished_at(
2383
+ account_states: list[AutomationRunAccountStateData],
2384
+ *,
2385
+ pending_accounts: int,
2386
+ ) -> datetime | None:
2387
+ if pending_accounts > 0:
2388
+ return None
2389
+ return max((entry.finished_at for entry in account_states if entry.finished_at is not None), default=None)
2390
+
2391
+
2392
+ def _resolve_cycle_error_summary(
2393
+ account_states: list[AutomationRunAccountStateData],
2394
+ ) -> tuple[str | None, str | None]:
2395
+ failed_states = [
2396
+ entry
2397
+ for entry in account_states
2398
+ if entry.status in {AUTOMATION_RUN_STATUS_FAILED, AUTOMATION_RUN_STATUS_PARTIAL}
2399
+ ]
2400
+ if not failed_states:
2401
+ return None, None
2402
+ if len(failed_states) == 1:
2403
+ failure = failed_states[0]
2404
+ if failure.error_code or failure.error_message:
2405
+ return failure.error_code, failure.error_message
2406
+ return "account_failure", "1 account failed in this cycle"
2407
+
2408
+ shared_codes = {entry.error_code for entry in failed_states if entry.error_code}
2409
+ error_code = next(iter(shared_codes)) if len(shared_codes) == 1 else "multiple_account_failures"
2410
+ error_message = f"{len(failed_states)} accounts failed in this cycle"
2411
+ return error_code, error_message
2412
+
2413
+
2414
+ def _extract_proxy_error(exc: ProxyResponseError) -> tuple[str, str]:
2415
+ payload = exc.payload
2416
+ if isinstance(payload, dict):
2417
+ error = payload.get("error")
2418
+ if isinstance(error, dict):
2419
+ code_value = error.get("code")
2420
+ message_value = error.get("message")
2421
+ code = (
2422
+ code_value.strip().lower() if isinstance(code_value, str) and code_value.strip() else "upstream_error"
2423
+ )
2424
+ if isinstance(message_value, str) and message_value.strip():
2425
+ return code, message_value.strip()
2426
+ return code, "Upstream request failed"
2427
+ return "upstream_error", "Upstream request failed"
2428
+
2429
+
2430
+ def _normalize_job_status_filters(values: list[str] | None) -> list[str] | None:
2431
+ if not values:
2432
+ return None
2433
+ normalized = sorted({value.strip().lower() for value in values if value and value.strip()})
2434
+ if not normalized or "all" in normalized:
2435
+ return None
2436
+ return normalized
2437
+
2438
+
2439
+ def _normalize_run_status_filters(values: list[str] | None) -> list[str] | None:
2440
+ if not values:
2441
+ return None
2442
+ normalized = sorted({value.strip().lower() for value in values if value and value.strip()})
2443
+ if not normalized or "all" in normalized:
2444
+ return None
2445
+ return [value for value in normalized if value in _all_run_statuses()] or None
2446
+
2447
+
2448
+ def _normalize_run_trigger_filters(values: list[str] | None) -> list[str] | None:
2449
+ if not values:
2450
+ return None
2451
+ normalized = sorted({value.strip().lower() for value in values if value and value.strip()})
2452
+ if not normalized or "all" in normalized:
2453
+ return None
2454
+ return [value for value in normalized if value in _all_run_triggers()] or None
2455
+
2456
+
2457
+ def _all_run_statuses() -> list[str]:
2458
+ return [
2459
+ AUTOMATION_RUN_STATUS_RUNNING,
2460
+ AUTOMATION_RUN_STATUS_SUCCESS,
2461
+ AUTOMATION_RUN_STATUS_FAILED,
2462
+ AUTOMATION_RUN_STATUS_PARTIAL,
2463
+ ]
2464
+
2465
+
2466
+ def _all_run_triggers() -> list[str]:
2467
+ return [AUTOMATION_RUN_TRIGGER_SCHEDULED, AUTOMATION_RUN_TRIGGER_MANUAL]
2468
+
2469
+
2470
+ def _automation_request_id(response_id: str | None, run_id: str, attempt_count: int) -> str:
2471
+ if isinstance(response_id, str):
2472
+ normalized = response_id.strip()
2473
+ if normalized:
2474
+ return normalized
2475
+ return f"automation-{run_id}-attempt-{attempt_count}"
2476
+
2477
+
2478
+ def _automation_compact_request_timeout_seconds(run: AutomationRunRecord) -> float:
2479
+ """The compact budget the run was claimed under; the current budget for legacy rows.
2480
+
2481
+ The reclaim window is derived from the same value, so a run can never be
2482
+ reclaimed while its own compact request may still be in flight.
2483
+ """
2484
+ if run.claim_budget_seconds is not None:
2485
+ return run.claim_budget_seconds
2486
+ return effective_compact_request_budget_seconds()
2487
+
2488
+
2489
+ def _run_stale_started_before(
2490
+ run: AutomationRunRecord,
2491
+ *,
2492
+ now_utc: datetime,
2493
+ fallback_budget_seconds: float,
2494
+ ) -> datetime:
2495
+ return run_stale_started_before(
2496
+ now_utc=now_utc,
2497
+ claim_budget_seconds=run.claim_budget_seconds,
2498
+ fallback_budget_seconds=fallback_budget_seconds,
2499
+ )
2500
+
2501
+
2502
+ def _is_reclaimable_running_claim(
2503
+ run: AutomationRunRecord,
2504
+ *,
2505
+ now_utc: datetime,
2506
+ fallback_budget_seconds: float,
2507
+ ) -> bool:
2508
+ """True for an unclaimed placeholder or a claim held past its own reclaim window."""
2509
+ return run.started_at <= run.scheduled_for or run.started_at < _run_stale_started_before(
2510
+ run,
2511
+ now_utc=now_utc,
2512
+ fallback_budget_seconds=fallback_budget_seconds,
2513
+ )
2514
+
2515
+
2516
+ def _elapsed_ms(started_at: float | None) -> int | None:
2517
+ if started_at is None:
2518
+ return None
2519
+ return max(0, int((time.monotonic() - started_at) * 1000))
2520
+
2521
+
2522
+ def _extract_compact_usage_fields(
2523
+ compact_response: object,
2524
+ ) -> tuple[int | None, int | None, int | None, int | None, str | None]:
2525
+ usage = getattr(compact_response, "usage", None)
2526
+ input_tokens = _coerce_int(getattr(usage, "input_tokens", None))
2527
+ output_tokens = _coerce_int(getattr(usage, "output_tokens", None))
2528
+ input_details = getattr(usage, "input_tokens_details", None)
2529
+ output_details = getattr(usage, "output_tokens_details", None)
2530
+ cached_input_tokens = _coerce_int(getattr(input_details, "cached_tokens", None))
2531
+ reasoning_tokens = _coerce_int(getattr(output_details, "reasoning_tokens", None))
2532
+
2533
+ service_tier: str | None = None
2534
+ model_extra = getattr(compact_response, "model_extra", None)
2535
+ if isinstance(model_extra, dict):
2536
+ service_tier_raw = model_extra.get("service_tier")
2537
+ if isinstance(service_tier_raw, str):
2538
+ normalized = service_tier_raw.strip()
2539
+ if normalized:
2540
+ service_tier = normalized
2541
+
2542
+ return input_tokens, output_tokens, cached_input_tokens, reasoning_tokens, service_tier
2543
+
2544
+
2545
+ def _coerce_int(value: object) -> int | None:
2546
+ if isinstance(value, int):
2547
+ return value
2548
+ return None