codexLB-up 1.25.0b10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +10 -0
- app/admin_cli.py +494 -0
- app/cli.py +339 -0
- app/codex_sessions_retag.py +487 -0
- app/core/__init__.py +0 -0
- app/core/audit/__init__.py +0 -0
- app/core/audit/service.py +216 -0
- app/core/audit/types.py +94 -0
- app/core/auth/__init__.py +236 -0
- app/core/auth/api_key_cache.py +65 -0
- app/core/auth/dashboard_access.py +429 -0
- app/core/auth/dashboard_mode.py +171 -0
- app/core/auth/dashboard_session_ttl.py +74 -0
- app/core/auth/dashboard_users_cache.py +120 -0
- app/core/auth/dependencies.py +710 -0
- app/core/auth/external_identity.py +78 -0
- app/core/auth/guardian.py +374 -0
- app/core/auth/models.py +49 -0
- app/core/auth/providers/__init__.py +129 -0
- app/core/auth/providers/oidc.py +838 -0
- app/core/auth/providers/registry.py +118 -0
- app/core/auth/refresh.py +394 -0
- app/core/auth/step_up.py +117 -0
- app/core/auth/totp.py +81 -0
- app/core/balancer/__init__.py +71 -0
- app/core/balancer/logic.py +1612 -0
- app/core/balancer/rendezvous_hash.py +21 -0
- app/core/balancer/types.py +21 -0
- app/core/bootstrap.py +172 -0
- app/core/cache/invalidation.py +426 -0
- app/core/clients/__init__.py +0 -0
- app/core/clients/codex.py +996 -0
- app/core/clients/codex_version.py +187 -0
- app/core/clients/codex_version_snapshot.py +2 -0
- app/core/clients/files.py +534 -0
- app/core/clients/headers.py +29 -0
- app/core/clients/http.py +511 -0
- app/core/clients/model_fetcher.py +276 -0
- app/core/clients/native_egress.py +1304 -0
- app/core/clients/oauth.py +477 -0
- app/core/clients/proxy.py +6089 -0
- app/core/clients/proxy_websocket.py +1411 -0
- app/core/clients/rate_limit_reset_credits.py +412 -0
- app/core/clients/stream_errors.py +12 -0
- app/core/clients/thread_cache_identity.py +347 -0
- app/core/clients/usage.py +467 -0
- app/core/clock.py +170 -0
- app/core/config/__init__.py +0 -0
- app/core/config/background_jobs.py +73 -0
- app/core/config/context_window_overrides.py +166 -0
- app/core/config/dashboard_overrides.py +145 -0
- app/core/config/inheritable.py +47 -0
- app/core/config/key_fingerprint.py +115 -0
- app/core/config/settings.py +872 -0
- app/core/config/settings_cache.py +100 -0
- app/core/config/spool_retention.py +217 -0
- app/core/config/tiers.py +174 -0
- app/core/conversation_archive.py +568 -0
- app/core/crypto.py +37 -0
- app/core/draining.py +11 -0
- app/core/errors.py +365 -0
- app/core/exceptions.py +155 -0
- app/core/handlers/__init__.py +3 -0
- app/core/handlers/exceptions.py +478 -0
- app/core/http_protocol.py +176 -0
- app/core/http_protocol_httptools.py +204 -0
- app/core/ingress_limits.py +17 -0
- app/core/metadata_store.py +18 -0
- app/core/metrics/__init__.py +0 -0
- app/core/metrics/middleware.py +74 -0
- app/core/metrics/prometheus.py +604 -0
- app/core/middleware/__init__.py +25 -0
- app/core/middleware/api_firewall.py +111 -0
- app/core/middleware/app_version.py +37 -0
- app/core/middleware/dashboard_auth_proxy.py +55 -0
- app/core/middleware/dashboard_csrf.py +177 -0
- app/core/middleware/dashboard_gzip.py +41 -0
- app/core/middleware/dashboard_overrides.py +59 -0
- app/core/middleware/firewall_cache.py +91 -0
- app/core/middleware/inflight.py +126 -0
- app/core/middleware/multipart_content_encoding.py +129 -0
- app/core/middleware/path_rewrite.py +148 -0
- app/core/middleware/request_body_limit.py +196 -0
- app/core/middleware/request_decompression.py +209 -0
- app/core/middleware/request_id.py +72 -0
- app/core/middleware/required_capability_http.py +116 -0
- app/core/middleware/trusted_proxy_headers.py +36 -0
- app/core/multipart.py +237 -0
- app/core/multipart_fields.py +91 -0
- app/core/openai/__init__.py +0 -0
- app/core/openai/chat_requests.py +493 -0
- app/core/openai/chat_responses.py +931 -0
- app/core/openai/contracts.py +46 -0
- app/core/openai/exceptions.py +24 -0
- app/core/openai/host_models.py +14 -0
- app/core/openai/images.py +413 -0
- app/core/openai/message_coercion.py +376 -0
- app/core/openai/model_refresh_scheduler.py +423 -0
- app/core/openai/model_registry.py +997 -0
- app/core/openai/model_registry_store.py +548 -0
- app/core/openai/models.py +172 -0
- app/core/openai/parsing.py +97 -0
- app/core/openai/requests.py +1980 -0
- app/core/openai/strict_schema.py +199 -0
- app/core/openai/tool_call_safety.py +99 -0
- app/core/openai/v1_requests.py +187 -0
- app/core/plan_types.py +90 -0
- app/core/prestop.py +202 -0
- app/core/rate_limiter/__init__.py +0 -0
- app/core/rate_limiter/db_rate_limiter.py +215 -0
- app/core/request_locality.py +473 -0
- app/core/resilience/__init__.py +0 -0
- app/core/resilience/backpressure.py +57 -0
- app/core/resilience/bulkhead.py +202 -0
- app/core/resilience/circuit_breaker.py +150 -0
- app/core/resilience/degradation.py +55 -0
- app/core/resilience/loop_lag_monitor.py +60 -0
- app/core/resilience/memory_monitor.py +137 -0
- app/core/resilience/network_recovery.py +256 -0
- app/core/resilience/overload.py +98 -0
- app/core/resilience/toggles.py +114 -0
- app/core/retention/__init__.py +0 -0
- app/core/retention/job.py +240 -0
- app/core/retention/scheduler.py +69 -0
- app/core/runtime_logging.py +601 -0
- app/core/scheduling/__init__.py +0 -0
- app/core/scheduling/leader_election.py +993 -0
- app/core/scheduling/leader_election_handle.py +25 -0
- app/core/server.py +140 -0
- app/core/shutdown.py +326 -0
- app/core/socket_peer.py +22 -0
- app/core/startup.py +41 -0
- app/core/timeout_invariants.py +347 -0
- app/core/tracing/__init__.py +0 -0
- app/core/tracing/otel.py +207 -0
- app/core/types.py +6 -0
- app/core/upstream_proxy/__init__.py +13 -0
- app/core/upstream_proxy/cache.py +130 -0
- app/core/upstream_proxy/resolver.py +189 -0
- app/core/upstream_proxy/types.py +104 -0
- app/core/usage/__init__.py +407 -0
- app/core/usage/depletion.py +109 -0
- app/core/usage/live_hub.py +41 -0
- app/core/usage/live_snapshots.py +174 -0
- app/core/usage/logs.py +212 -0
- app/core/usage/metadata_scheduler.py +118 -0
- app/core/usage/models.py +54 -0
- app/core/usage/pricing.py +576 -0
- app/core/usage/pricing_catalog.py +241 -0
- app/core/usage/pricing_snapshot.json +821 -0
- app/core/usage/quota.py +133 -0
- app/core/usage/refresh_policy.py +21 -0
- app/core/usage/refresh_scheduler.py +713 -0
- app/core/usage/reset_credits_refresh_scheduler.py +435 -0
- app/core/usage/types.py +168 -0
- app/core/utils/__init__.py +0 -0
- app/core/utils/json_guards.py +18 -0
- app/core/utils/locks.py +33 -0
- app/core/utils/masking.py +20 -0
- app/core/utils/proxy_env.py +104 -0
- app/core/utils/request_id.py +65 -0
- app/core/utils/retry.py +77 -0
- app/core/utils/shared_future.py +172 -0
- app/core/utils/sse.py +286 -0
- app/core/utils/time.py +24 -0
- app/db/__init__.py +0 -0
- app/db/account_identity_lock.py +56 -0
- app/db/alembic/__init__.py +1 -0
- app/db/alembic/env.py +70 -0
- app/db/alembic/revision_ids.py +43 -0
- app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
- app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
- app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
- app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
- app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
- app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
- app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
- app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
- app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
- app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
- app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
- app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
- app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
- app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
- app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
- app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
- app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
- app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
- app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
- app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
- app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
- app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
- app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
- app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
- app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
- app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
- app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
- app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
- app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
- app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
- app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
- app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
- app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
- app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
- app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
- app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
- app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
- app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
- app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
- app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
- app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
- app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
- app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
- app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
- app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
- app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
- app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
- app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
- app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
- app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
- app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
- app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
- app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
- app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
- app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
- app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
- app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
- app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
- app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
- app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
- app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
- app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
- app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
- app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
- app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
- app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
- app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
- app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
- app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
- app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
- app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
- app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
- app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
- app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
- app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
- app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
- app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
- app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
- app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
- app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
- app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
- app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
- app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
- app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
- app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
- app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
- app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
- app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
- app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
- app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
- app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
- app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
- app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
- app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
- app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
- app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
- app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
- app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
- app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
- app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
- app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
- app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
- app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
- app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
- app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
- app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
- app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
- app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
- app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
- app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
- app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
- app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
- app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
- app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
- app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
- app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
- app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
- app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
- app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
- app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
- app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
- app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
- app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
- app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
- app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
- app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
- app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
- app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
- app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
- app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
- app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
- app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
- app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
- app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
- app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
- app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
- app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
- app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
- app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
- app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
- app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
- app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
- app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
- app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
- app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
- app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
- app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
- app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
- app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
- app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
- app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
- app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
- app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
- app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
- app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
- app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
- app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
- app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
- app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
- app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
- app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
- app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
- app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
- app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
- app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
- app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
- app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
- app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
- app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
- app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
- app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
- app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
- app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
- app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
- app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
- app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
- app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
- app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
- app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
- app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
- app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
- app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
- app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
- app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
- app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
- app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
- app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
- app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
- app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
- app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
- app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
- app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
- app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
- app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
- app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
- app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
- app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
- app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
- app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
- app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
- app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
- app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
- app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
- app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
- app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
- app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
- app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
- app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
- app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
- app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
- app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
- app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
- app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
- app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
- app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
- app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
- app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
- app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
- app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
- app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
- app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
- app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
- app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
- app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
- app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
- app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
- app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
- app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
- app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
- app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
- app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
- app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
- app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
- app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
- app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
- app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
- app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
- app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
- app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
- app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
- app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
- app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
- app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
- app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
- app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
- app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
- app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
- app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
- app/db/alembic/versions/__init__.py +1 -0
- app/db/backup.py +64 -0
- app/db/migrate.py +1249 -0
- app/db/migration_lock.py +160 -0
- app/db/migration_url.py +18 -0
- app/db/models.py +3100 -0
- app/db/recover.py +244 -0
- app/db/session.py +1295 -0
- app/db/snapshot.py +46 -0
- app/db/sqlite_lock_retry.py +190 -0
- app/db/sqlite_utils.py +549 -0
- app/dependencies.py +462 -0
- app/main.py +1240 -0
- app/modules/__init__.py +0 -0
- app/modules/accounts/__init__.py +0 -0
- app/modules/accounts/api.py +426 -0
- app/modules/accounts/auth_manager.py +1439 -0
- app/modules/accounts/background_repository.py +160 -0
- app/modules/accounts/deletion.py +540 -0
- app/modules/accounts/mappers.py +654 -0
- app/modules/accounts/refresh_claims.py +352 -0
- app/modules/accounts/repository.py +1679 -0
- app/modules/accounts/schemas.py +308 -0
- app/modules/accounts/service.py +809 -0
- app/modules/accounts/usage_rollup.py +486 -0
- app/modules/accounts/usage_rollup_scheduler.py +75 -0
- app/modules/accounts/usage_time_rollup.py +1037 -0
- app/modules/accounts/usage_time_rollup_read.py +522 -0
- app/modules/api_keys/__init__.py +1 -0
- app/modules/api_keys/api.py +344 -0
- app/modules/api_keys/last_used_coalescer.py +216 -0
- app/modules/api_keys/limit_windows.py +41 -0
- app/modules/api_keys/repository.py +1200 -0
- app/modules/api_keys/reset_scheduler.py +85 -0
- app/modules/api_keys/schemas.py +135 -0
- app/modules/api_keys/service.py +2123 -0
- app/modules/audit/__init__.py +0 -0
- app/modules/audit/api.py +88 -0
- app/modules/audit/repository.py +74 -0
- app/modules/audit/schemas.py +33 -0
- app/modules/audit/service.py +71 -0
- app/modules/auth_providers/__init__.py +0 -0
- app/modules/auth_providers/api.py +127 -0
- app/modules/auth_providers/config.py +343 -0
- app/modules/auth_providers/repository.py +101 -0
- app/modules/auth_providers/schemas.py +88 -0
- app/modules/auth_providers/seed.py +105 -0
- app/modules/auth_providers/service.py +289 -0
- app/modules/automations/__init__.py +1 -0
- app/modules/automations/api.py +369 -0
- app/modules/automations/repository.py +1896 -0
- app/modules/automations/scheduler.py +107 -0
- app/modules/automations/schemas.py +158 -0
- app/modules/automations/service.py +2548 -0
- app/modules/cache_isolation_probe/__init__.py +0 -0
- app/modules/cache_isolation_probe/api.py +188 -0
- app/modules/cache_isolation_probe/prefix.py +144 -0
- app/modules/cache_isolation_probe/schemas.py +94 -0
- app/modules/cache_isolation_probe/sender.py +229 -0
- app/modules/cache_isolation_probe/service.py +525 -0
- app/modules/conversation_archive/__init__.py +1 -0
- app/modules/conversation_archive/api.py +101 -0
- app/modules/conversation_archive/schemas.py +28 -0
- app/modules/conversation_archive/service.py +165 -0
- app/modules/dashboard/__init__.py +1 -0
- app/modules/dashboard/api.py +106 -0
- app/modules/dashboard/builders.py +107 -0
- app/modules/dashboard/repository.py +304 -0
- app/modules/dashboard/schemas.py +148 -0
- app/modules/dashboard/service.py +534 -0
- app/modules/dashboard/timeframes.py +19 -0
- app/modules/dashboard/weekly_pace.py +617 -0
- app/modules/dashboard_auth/__init__.py +1 -0
- app/modules/dashboard_auth/api.py +1344 -0
- app/modules/dashboard_auth/oidc_api.py +753 -0
- app/modules/dashboard_auth/oidc_flows.py +330 -0
- app/modules/dashboard_auth/repository.py +452 -0
- app/modules/dashboard_auth/schemas.py +232 -0
- app/modules/dashboard_auth/service.py +1384 -0
- app/modules/dashboard_roles/__init__.py +0 -0
- app/modules/dashboard_roles/api.py +64 -0
- app/modules/dashboard_roles/repository.py +54 -0
- app/modules/dashboard_roles/schemas.py +29 -0
- app/modules/dashboard_roles/seed.py +80 -0
- app/modules/dashboard_roles/service.py +142 -0
- app/modules/dashboard_users/__init__.py +0 -0
- app/modules/dashboard_users/api.py +278 -0
- app/modules/dashboard_users/break_glass.py +169 -0
- app/modules/dashboard_users/credentials.py +18 -0
- app/modules/dashboard_users/identity_resolver.py +665 -0
- app/modules/dashboard_users/repository.py +725 -0
- app/modules/dashboard_users/schemas.py +117 -0
- app/modules/dashboard_users/service.py +1054 -0
- app/modules/firewall/__init__.py +1 -0
- app/modules/firewall/api.py +78 -0
- app/modules/firewall/repository.py +51 -0
- app/modules/firewall/schemas.py +28 -0
- app/modules/firewall/service.py +93 -0
- app/modules/fleet/__init__.py +1 -0
- app/modules/fleet/api.py +177 -0
- app/modules/fleet/mappers.py +56 -0
- app/modules/fleet/observability.py +372 -0
- app/modules/fleet/schemas.py +119 -0
- app/modules/health/__init__.py +0 -0
- app/modules/health/api.py +246 -0
- app/modules/health/schemas.py +27 -0
- app/modules/limit_warmup/__init__.py +1 -0
- app/modules/limit_warmup/repository.py +159 -0
- app/modules/limit_warmup/service.py +1001 -0
- app/modules/model_sources/__init__.py +1 -0
- app/modules/model_sources/api.py +99 -0
- app/modules/model_sources/catalog.py +307 -0
- app/modules/model_sources/forwarding.py +1435 -0
- app/modules/model_sources/projection.py +55 -0
- app/modules/model_sources/repository.py +201 -0
- app/modules/model_sources/schemas.py +79 -0
- app/modules/model_sources/selection.py +166 -0
- app/modules/model_sources/service.py +236 -0
- app/modules/oauth/__init__.py +0 -0
- app/modules/oauth/api.py +101 -0
- app/modules/oauth/repository.py +238 -0
- app/modules/oauth/schemas.py +49 -0
- app/modules/oauth/service.py +1196 -0
- app/modules/oauth/templates/oauth_success.html +122 -0
- app/modules/proxy/__init__.py +0 -0
- app/modules/proxy/_load_balancer/__init__.py +1 -0
- app/modules/proxy/_load_balancer/error_rate.py +116 -0
- app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
- app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
- app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
- app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
- app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
- app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
- app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
- app/modules/proxy/_load_balancer/tunables.py +97 -0
- app/modules/proxy/_load_balancer/types.py +96 -0
- app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
- app/modules/proxy/_service/__init__.py +6 -0
- app/modules/proxy/_service/api_key_usage.py +745 -0
- app/modules/proxy/_service/clock_budget.py +38 -0
- app/modules/proxy/_service/codex_control.py +611 -0
- app/modules/proxy/_service/compact.py +2211 -0
- app/modules/proxy/_service/file_ops.py +749 -0
- app/modules/proxy/_service/http_bridge/__init__.py +3 -0
- app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
- app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
- app/modules/proxy/_service/http_bridge/activity.py +178 -0
- app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
- app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
- app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
- app/modules/proxy/_service/http_bridge/protocol.py +167 -0
- app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
- app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
- app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
- app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
- app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
- app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
- app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
- app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
- app/modules/proxy/_service/observability.py +410 -0
- app/modules/proxy/_service/rate_limit.py +361 -0
- app/modules/proxy/_service/realtime_live.py +655 -0
- app/modules/proxy/_service/refresh.py +154 -0
- app/modules/proxy/_service/request_log.py +585 -0
- app/modules/proxy/_service/response_create.py +930 -0
- app/modules/proxy/_service/streaming/__init__.py +3 -0
- app/modules/proxy/_service/streaming/helpers.py +1317 -0
- app/modules/proxy/_service/streaming/mixin.py +1100 -0
- app/modules/proxy/_service/streaming/protocol.py +32 -0
- app/modules/proxy/_service/streaming/retry.py +3762 -0
- app/modules/proxy/_service/support.py +2122 -0
- app/modules/proxy/_service/transcribe.py +434 -0
- app/modules/proxy/_service/warmup.py +483 -0
- app/modules/proxy/_service/websocket/__init__.py +3 -0
- app/modules/proxy/_service/websocket/helpers.py +2360 -0
- app/modules/proxy/_service/websocket/mixin.py +7285 -0
- app/modules/proxy/_service/websocket/protocol.py +74 -0
- app/modules/proxy/account_cache.py +229 -0
- app/modules/proxy/account_eligibility.py +87 -0
- app/modules/proxy/additional_model_limits.py +40 -0
- app/modules/proxy/affinity.py +830 -0
- app/modules/proxy/affinity_observation.py +101 -0
- app/modules/proxy/api.py +10254 -0
- app/modules/proxy/api_key_usage.py +113 -0
- app/modules/proxy/cap_partitioning.py +280 -0
- app/modules/proxy/capability_lineage.py +65 -0
- app/modules/proxy/capability_lineage_repository.py +82 -0
- app/modules/proxy/capability_routing.py +253 -0
- app/modules/proxy/continuity.py +112 -0
- app/modules/proxy/downstream_delivery.py +277 -0
- app/modules/proxy/durable_bridge_coordinator.py +1217 -0
- app/modules/proxy/durable_bridge_repository.py +4516 -0
- app/modules/proxy/durable_bridge_runtime.py +9 -0
- app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
- app/modules/proxy/fair_share.py +131 -0
- app/modules/proxy/file_pin_repository.py +210 -0
- app/modules/proxy/helpers.py +439 -0
- app/modules/proxy/http_bridge_event_batcher.py +613 -0
- app/modules/proxy/http_bridge_forwarding.py +797 -0
- app/modules/proxy/http_continuation.py +76 -0
- app/modules/proxy/images_observability.py +62 -0
- app/modules/proxy/images_service.py +1054 -0
- app/modules/proxy/load_balancer.py +3016 -0
- app/modules/proxy/rate_limit_cache.py +46 -0
- app/modules/proxy/replay_safety.py +1063 -0
- app/modules/proxy/repo_bundle.py +31 -0
- app/modules/proxy/request_policy.py +1054 -0
- app/modules/proxy/ring_membership.py +251 -0
- app/modules/proxy/schemas.py +345 -0
- app/modules/proxy/selection_errors.py +41 -0
- app/modules/proxy/service.py +2600 -0
- app/modules/proxy/source_admission.py +125 -0
- app/modules/proxy/source_dispatch.py +999 -0
- app/modules/proxy/sticky_repository.py +831 -0
- app/modules/proxy/thread_anchors.py +491 -0
- app/modules/proxy/tool_call_dedupe.py +802 -0
- app/modules/proxy/types.py +54 -0
- app/modules/proxy/work_admission.py +121 -0
- app/modules/quota_planner/__init__.py +1 -0
- app/modules/quota_planner/api.py +276 -0
- app/modules/quota_planner/logic.py +743 -0
- app/modules/quota_planner/repository.py +722 -0
- app/modules/quota_planner/scheduler.py +212 -0
- app/modules/quota_planner/schemas.py +102 -0
- app/modules/quota_planner/warmup.py +693 -0
- app/modules/rate_limit_reset_credits/__init__.py +0 -0
- app/modules/rate_limit_reset_credits/api.py +601 -0
- app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
- app/modules/rate_limit_reset_credits/store.py +114 -0
- app/modules/reports/__init__.py +1 -0
- app/modules/reports/api.py +134 -0
- app/modules/reports/cache.py +74 -0
- app/modules/reports/filters.py +27 -0
- app/modules/reports/repository.py +525 -0
- app/modules/reports/rollup.py +150 -0
- app/modules/reports/rollup_read.py +148 -0
- app/modules/reports/schemas.py +123 -0
- app/modules/reports/service.py +287 -0
- app/modules/reports/thread_identity.py +256 -0
- app/modules/request_logs/__init__.py +0 -0
- app/modules/request_logs/api.py +224 -0
- app/modules/request_logs/cost_backfill.py +154 -0
- app/modules/request_logs/mappers.py +97 -0
- app/modules/request_logs/repository.py +1765 -0
- app/modules/request_logs/schemas.py +145 -0
- app/modules/request_logs/service.py +348 -0
- app/modules/role_mappings/__init__.py +0 -0
- app/modules/role_mappings/api.py +206 -0
- app/modules/role_mappings/matching.py +55 -0
- app/modules/role_mappings/repository.py +106 -0
- app/modules/role_mappings/schemas.py +67 -0
- app/modules/role_mappings/service.py +334 -0
- app/modules/runtime/__init__.py +1 -0
- app/modules/runtime/api.py +18 -0
- app/modules/runtime/schemas.py +14 -0
- app/modules/runtime/service.py +218 -0
- app/modules/scim/__init__.py +1 -0
- app/modules/scim/api.py +194 -0
- app/modules/scim/dependencies.py +112 -0
- app/modules/scim/errors.py +77 -0
- app/modules/scim/management_api.py +156 -0
- app/modules/scim/repository.py +212 -0
- app/modules/scim/schemas.py +145 -0
- app/modules/scim/service.py +786 -0
- app/modules/scim/tokens.py +43 -0
- app/modules/settings/__init__.py +1 -0
- app/modules/settings/api.py +1677 -0
- app/modules/settings/repository.py +606 -0
- app/modules/settings/schemas.py +496 -0
- app/modules/settings/service.py +769 -0
- app/modules/shared/__init__.py +0 -0
- app/modules/shared/schemas.py +22 -0
- app/modules/sticky_sessions/__init__.py +1 -0
- app/modules/sticky_sessions/api.py +118 -0
- app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
- app/modules/sticky_sessions/schemas.py +78 -0
- app/modules/sticky_sessions/service.py +196 -0
- app/modules/telemetry/__init__.py +5 -0
- app/modules/telemetry/api.py +115 -0
- app/modules/telemetry/clients.py +62 -0
- app/modules/telemetry/consent.py +115 -0
- app/modules/telemetry/scheduler.py +85 -0
- app/modules/telemetry/schemas.py +163 -0
- app/modules/telemetry/sender.py +221 -0
- app/modules/telemetry/snapshot.py +490 -0
- app/modules/usage/__init__.py +0 -0
- app/modules/usage/additional_quota_keys.py +379 -0
- app/modules/usage/api.py +45 -0
- app/modules/usage/background_repository.py +147 -0
- app/modules/usage/builders.py +559 -0
- app/modules/usage/depletion_service.py +277 -0
- app/modules/usage/live_ingest.py +356 -0
- app/modules/usage/mappers.py +29 -0
- app/modules/usage/plan_downgrade_observations.py +488 -0
- app/modules/usage/repository.py +1890 -0
- app/modules/usage/schemas.py +68 -0
- app/modules/usage/service.py +102 -0
- app/modules/usage/updater.py +1488 -0
- app/static/assets/Trans-Cj-J9mVA.js +1 -0
- app/static/assets/access-page-DHzZiXZi.js +1 -0
- app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
- app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
- app/static/assets/account-status-DIsU5sUS.js +1 -0
- app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
- app/static/assets/accounts-page-Bbai8ift.js +3 -0
- app/static/assets/api-Dwa7XxPe.js +1 -0
- app/static/assets/api-client-dugX1Pti.js +55 -0
- app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
- app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
- app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
- app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
- app/static/assets/apis-page-BvXz9c6i.js +2 -0
- app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
- app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
- app/static/assets/automations-page-DQgNAJOR.js +3 -0
- app/static/assets/badge-DqMb9es0.js +1 -0
- app/static/assets/blur-email-HEvKagG2.js +1 -0
- app/static/assets/button-BjVmmiGW.js +1 -0
- app/static/assets/checkbox-apsl9vzk.js +1 -0
- app/static/assets/chevron-left-1d_pHVa5.js +1 -0
- app/static/assets/circle-alert-BS0xd_o1.js +1 -0
- app/static/assets/clsx-DB0hHKMi.js +1 -0
- app/static/assets/colors-BBy8GEtr.js +1 -0
- app/static/assets/copy-button-D7nAvOKR.js +1 -0
- app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
- app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
- app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
- app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
- app/static/assets/donut-chart-Bxc69cra.js +1 -0
- app/static/assets/download-kQteIcsB.js +1 -0
- app/static/assets/ellipsis-B_5OgIre.js +1 -0
- app/static/assets/empty-state-JcA7tc8m.js +1 -0
- app/static/assets/es2015-bMH8YKZT.js +41 -0
- app/static/assets/es6-B264ni6W.js +69 -0
- app/static/assets/external-link-UVAOgMQ0.js +1 -0
- app/static/assets/i18n-BWqZ9Ua2.js +1 -0
- app/static/assets/inbox-B_RM2s1J.js +1 -0
- app/static/assets/index-DIpd264Z.js +23 -0
- app/static/assets/index-DMeLKRJx.css +2 -0
- app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
- app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
- app/static/assets/iso-Bpe5Ihbb.js +1 -0
- app/static/assets/label-CGaLmLNf.js +1 -0
- app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
- app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
- app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
- app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
- app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
- app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
- app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
- app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
- app/static/assets/payload-CIYnGZz9.js +1 -0
- app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
- app/static/assets/report-chart-card-BXDliwcc.js +1 -0
- app/static/assets/reports-page-CGCJeMHG.js +3 -0
- app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
- app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
- app/static/assets/search-HfhqwERt.js +1 -0
- app/static/assets/select-Cl33bz-g.js +1 -0
- app/static/assets/settings-page-mZj01HYl.js +2 -0
- app/static/assets/shim-CZwyM4Q7.js +1 -0
- app/static/assets/skeleton-C_EUc4Rp.js +1 -0
- app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
- app/static/assets/status-badge-CHHA5X6c.js +1 -0
- app/static/assets/switch-CzxgokcM.js +1 -0
- app/static/assets/table-DXaNSQ0G.js +1 -0
- app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
- app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
- app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
- app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
- app/static/assets/trash-D3KyWr1h.js +1 -0
- app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
- app/static/assets/use-accounts-PWlVGw3z.js +1 -0
- app/static/assets/use-auth-CuDlW3iC.js +1 -0
- app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
- app/static/assets/use-model-sources-Bul24JBu.js +1 -0
- app/static/assets/use-models-DUCPUuZg.js +1 -0
- app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
- app/static/assets/useTranslation--56nHhlH.js +1 -0
- app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
- app/static/assets/users-uIC3TIlE.js +1 -0
- app/static/assets/vendor-query-_bHZuxEx.js +1 -0
- app/static/assets/vendor-react-BVL0YZPW.js +9 -0
- app/static/favicon.svg +10 -0
- app/static/fonts/GeistSans-Variable.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- app/static/index.html +29 -0
- codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
- codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
- codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
- codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
- codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
- config/additional_quota_registry.json +11 -0
|
@@ -0,0 +1,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
|