codexLB-up 1.25.0b10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (827) hide show
  1. app/__init__.py +10 -0
  2. app/admin_cli.py +494 -0
  3. app/cli.py +339 -0
  4. app/codex_sessions_retag.py +487 -0
  5. app/core/__init__.py +0 -0
  6. app/core/audit/__init__.py +0 -0
  7. app/core/audit/service.py +216 -0
  8. app/core/audit/types.py +94 -0
  9. app/core/auth/__init__.py +236 -0
  10. app/core/auth/api_key_cache.py +65 -0
  11. app/core/auth/dashboard_access.py +429 -0
  12. app/core/auth/dashboard_mode.py +171 -0
  13. app/core/auth/dashboard_session_ttl.py +74 -0
  14. app/core/auth/dashboard_users_cache.py +120 -0
  15. app/core/auth/dependencies.py +710 -0
  16. app/core/auth/external_identity.py +78 -0
  17. app/core/auth/guardian.py +374 -0
  18. app/core/auth/models.py +49 -0
  19. app/core/auth/providers/__init__.py +129 -0
  20. app/core/auth/providers/oidc.py +838 -0
  21. app/core/auth/providers/registry.py +118 -0
  22. app/core/auth/refresh.py +394 -0
  23. app/core/auth/step_up.py +117 -0
  24. app/core/auth/totp.py +81 -0
  25. app/core/balancer/__init__.py +71 -0
  26. app/core/balancer/logic.py +1612 -0
  27. app/core/balancer/rendezvous_hash.py +21 -0
  28. app/core/balancer/types.py +21 -0
  29. app/core/bootstrap.py +172 -0
  30. app/core/cache/invalidation.py +426 -0
  31. app/core/clients/__init__.py +0 -0
  32. app/core/clients/codex.py +996 -0
  33. app/core/clients/codex_version.py +187 -0
  34. app/core/clients/codex_version_snapshot.py +2 -0
  35. app/core/clients/files.py +534 -0
  36. app/core/clients/headers.py +29 -0
  37. app/core/clients/http.py +511 -0
  38. app/core/clients/model_fetcher.py +276 -0
  39. app/core/clients/native_egress.py +1304 -0
  40. app/core/clients/oauth.py +477 -0
  41. app/core/clients/proxy.py +6089 -0
  42. app/core/clients/proxy_websocket.py +1411 -0
  43. app/core/clients/rate_limit_reset_credits.py +412 -0
  44. app/core/clients/stream_errors.py +12 -0
  45. app/core/clients/thread_cache_identity.py +347 -0
  46. app/core/clients/usage.py +467 -0
  47. app/core/clock.py +170 -0
  48. app/core/config/__init__.py +0 -0
  49. app/core/config/background_jobs.py +73 -0
  50. app/core/config/context_window_overrides.py +166 -0
  51. app/core/config/dashboard_overrides.py +145 -0
  52. app/core/config/inheritable.py +47 -0
  53. app/core/config/key_fingerprint.py +115 -0
  54. app/core/config/settings.py +872 -0
  55. app/core/config/settings_cache.py +100 -0
  56. app/core/config/spool_retention.py +217 -0
  57. app/core/config/tiers.py +174 -0
  58. app/core/conversation_archive.py +568 -0
  59. app/core/crypto.py +37 -0
  60. app/core/draining.py +11 -0
  61. app/core/errors.py +365 -0
  62. app/core/exceptions.py +155 -0
  63. app/core/handlers/__init__.py +3 -0
  64. app/core/handlers/exceptions.py +478 -0
  65. app/core/http_protocol.py +176 -0
  66. app/core/http_protocol_httptools.py +204 -0
  67. app/core/ingress_limits.py +17 -0
  68. app/core/metadata_store.py +18 -0
  69. app/core/metrics/__init__.py +0 -0
  70. app/core/metrics/middleware.py +74 -0
  71. app/core/metrics/prometheus.py +604 -0
  72. app/core/middleware/__init__.py +25 -0
  73. app/core/middleware/api_firewall.py +111 -0
  74. app/core/middleware/app_version.py +37 -0
  75. app/core/middleware/dashboard_auth_proxy.py +55 -0
  76. app/core/middleware/dashboard_csrf.py +177 -0
  77. app/core/middleware/dashboard_gzip.py +41 -0
  78. app/core/middleware/dashboard_overrides.py +59 -0
  79. app/core/middleware/firewall_cache.py +91 -0
  80. app/core/middleware/inflight.py +126 -0
  81. app/core/middleware/multipart_content_encoding.py +129 -0
  82. app/core/middleware/path_rewrite.py +148 -0
  83. app/core/middleware/request_body_limit.py +196 -0
  84. app/core/middleware/request_decompression.py +209 -0
  85. app/core/middleware/request_id.py +72 -0
  86. app/core/middleware/required_capability_http.py +116 -0
  87. app/core/middleware/trusted_proxy_headers.py +36 -0
  88. app/core/multipart.py +237 -0
  89. app/core/multipart_fields.py +91 -0
  90. app/core/openai/__init__.py +0 -0
  91. app/core/openai/chat_requests.py +493 -0
  92. app/core/openai/chat_responses.py +931 -0
  93. app/core/openai/contracts.py +46 -0
  94. app/core/openai/exceptions.py +24 -0
  95. app/core/openai/host_models.py +14 -0
  96. app/core/openai/images.py +413 -0
  97. app/core/openai/message_coercion.py +376 -0
  98. app/core/openai/model_refresh_scheduler.py +423 -0
  99. app/core/openai/model_registry.py +997 -0
  100. app/core/openai/model_registry_store.py +548 -0
  101. app/core/openai/models.py +172 -0
  102. app/core/openai/parsing.py +97 -0
  103. app/core/openai/requests.py +1980 -0
  104. app/core/openai/strict_schema.py +199 -0
  105. app/core/openai/tool_call_safety.py +99 -0
  106. app/core/openai/v1_requests.py +187 -0
  107. app/core/plan_types.py +90 -0
  108. app/core/prestop.py +202 -0
  109. app/core/rate_limiter/__init__.py +0 -0
  110. app/core/rate_limiter/db_rate_limiter.py +215 -0
  111. app/core/request_locality.py +473 -0
  112. app/core/resilience/__init__.py +0 -0
  113. app/core/resilience/backpressure.py +57 -0
  114. app/core/resilience/bulkhead.py +202 -0
  115. app/core/resilience/circuit_breaker.py +150 -0
  116. app/core/resilience/degradation.py +55 -0
  117. app/core/resilience/loop_lag_monitor.py +60 -0
  118. app/core/resilience/memory_monitor.py +137 -0
  119. app/core/resilience/network_recovery.py +256 -0
  120. app/core/resilience/overload.py +98 -0
  121. app/core/resilience/toggles.py +114 -0
  122. app/core/retention/__init__.py +0 -0
  123. app/core/retention/job.py +240 -0
  124. app/core/retention/scheduler.py +69 -0
  125. app/core/runtime_logging.py +601 -0
  126. app/core/scheduling/__init__.py +0 -0
  127. app/core/scheduling/leader_election.py +993 -0
  128. app/core/scheduling/leader_election_handle.py +25 -0
  129. app/core/server.py +140 -0
  130. app/core/shutdown.py +326 -0
  131. app/core/socket_peer.py +22 -0
  132. app/core/startup.py +41 -0
  133. app/core/timeout_invariants.py +347 -0
  134. app/core/tracing/__init__.py +0 -0
  135. app/core/tracing/otel.py +207 -0
  136. app/core/types.py +6 -0
  137. app/core/upstream_proxy/__init__.py +13 -0
  138. app/core/upstream_proxy/cache.py +130 -0
  139. app/core/upstream_proxy/resolver.py +189 -0
  140. app/core/upstream_proxy/types.py +104 -0
  141. app/core/usage/__init__.py +407 -0
  142. app/core/usage/depletion.py +109 -0
  143. app/core/usage/live_hub.py +41 -0
  144. app/core/usage/live_snapshots.py +174 -0
  145. app/core/usage/logs.py +212 -0
  146. app/core/usage/metadata_scheduler.py +118 -0
  147. app/core/usage/models.py +54 -0
  148. app/core/usage/pricing.py +576 -0
  149. app/core/usage/pricing_catalog.py +241 -0
  150. app/core/usage/pricing_snapshot.json +821 -0
  151. app/core/usage/quota.py +133 -0
  152. app/core/usage/refresh_policy.py +21 -0
  153. app/core/usage/refresh_scheduler.py +713 -0
  154. app/core/usage/reset_credits_refresh_scheduler.py +435 -0
  155. app/core/usage/types.py +168 -0
  156. app/core/utils/__init__.py +0 -0
  157. app/core/utils/json_guards.py +18 -0
  158. app/core/utils/locks.py +33 -0
  159. app/core/utils/masking.py +20 -0
  160. app/core/utils/proxy_env.py +104 -0
  161. app/core/utils/request_id.py +65 -0
  162. app/core/utils/retry.py +77 -0
  163. app/core/utils/shared_future.py +172 -0
  164. app/core/utils/sse.py +286 -0
  165. app/core/utils/time.py +24 -0
  166. app/db/__init__.py +0 -0
  167. app/db/account_identity_lock.py +56 -0
  168. app/db/alembic/__init__.py +1 -0
  169. app/db/alembic/env.py +70 -0
  170. app/db/alembic/revision_ids.py +43 -0
  171. app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
  172. app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
  173. app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
  174. app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
  175. app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
  176. app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
  177. app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
  178. app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
  179. app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
  180. app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
  181. app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
  182. app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
  183. app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
  184. app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
  185. app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
  186. app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
  187. app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
  188. app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
  189. app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
  190. app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
  191. app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
  192. app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
  193. app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
  194. app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
  195. app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
  196. app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
  197. app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
  198. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
  199. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
  200. app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
  201. app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
  202. app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
  203. app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
  204. app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
  205. app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
  206. app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
  207. app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
  208. app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
  209. app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
  210. app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
  211. app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
  212. app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
  213. app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
  214. app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
  215. app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
  216. app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
  217. app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
  218. app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
  219. app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
  220. app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
  221. app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
  222. app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
  223. app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
  224. app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
  225. app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
  226. app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
  227. app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
  228. app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
  229. app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
  230. app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
  231. app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
  232. app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
  233. app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
  234. app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
  235. app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
  236. app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
  237. app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
  238. app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
  239. app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
  240. app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
  241. app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
  242. app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
  243. app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
  244. app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
  245. app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
  246. app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
  247. app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
  248. app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
  249. app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
  250. app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
  251. app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
  252. app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
  253. app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
  254. app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
  255. app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
  256. app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
  257. app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
  258. app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
  259. app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
  260. app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
  261. app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
  262. app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
  263. app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
  264. app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
  265. app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
  266. app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
  267. app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
  268. app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
  269. app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
  270. app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
  271. app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
  272. app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
  273. app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
  274. app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
  275. app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
  276. app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
  277. app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
  278. app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
  279. app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
  280. app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
  281. app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
  282. app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
  283. app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
  284. app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
  285. app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
  286. app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
  287. app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
  288. app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
  289. app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
  290. app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
  291. app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
  292. app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
  293. app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
  294. app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
  295. app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
  296. app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
  297. app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
  298. app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
  299. app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
  300. app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
  301. app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
  302. app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
  303. app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
  304. app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
  305. app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
  306. app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
  307. app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
  308. app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
  309. app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
  310. app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
  311. app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
  312. app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
  313. app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
  314. app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
  315. app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
  316. app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
  317. app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
  318. app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
  319. app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
  320. app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
  321. app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
  322. app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
  323. app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
  324. app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
  325. app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
  326. app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
  327. app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
  328. app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
  329. app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
  330. app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
  331. app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
  332. app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
  333. app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
  334. app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
  335. app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
  336. app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
  337. app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
  338. app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
  339. app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
  340. app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
  341. app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
  342. app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
  343. app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
  344. app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
  345. app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
  346. app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
  347. app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
  348. app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
  349. app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
  350. app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
  351. app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
  352. app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
  353. app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
  354. app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
  355. app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
  356. app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
  357. app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
  358. app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
  359. app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
  360. app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
  361. app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
  362. app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
  363. app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
  364. app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
  365. app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
  366. app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
  367. app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
  368. app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
  369. app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
  370. app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
  371. app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
  372. app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
  373. app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
  374. app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
  375. app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
  376. app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
  377. app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
  378. app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
  379. app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
  380. app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
  381. app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
  382. app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
  383. app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
  384. app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
  385. app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
  386. app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
  387. app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
  388. app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
  389. app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
  390. app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
  391. app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
  392. app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
  393. app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
  394. app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
  395. app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
  396. app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
  397. app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
  398. app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
  399. app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
  400. app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
  401. app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
  402. app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
  403. app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
  404. app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
  405. app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
  406. app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
  407. app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
  408. app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
  409. app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
  410. app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
  411. app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
  412. app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
  413. app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
  414. app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
  415. app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
  416. app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
  417. app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
  418. app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
  419. app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
  420. app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
  421. app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
  422. app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
  423. app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
  424. app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
  425. app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
  426. app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
  427. app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
  428. app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
  429. app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
  430. app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
  431. app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
  432. app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
  433. app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
  434. app/db/alembic/versions/__init__.py +1 -0
  435. app/db/backup.py +64 -0
  436. app/db/migrate.py +1249 -0
  437. app/db/migration_lock.py +160 -0
  438. app/db/migration_url.py +18 -0
  439. app/db/models.py +3100 -0
  440. app/db/recover.py +244 -0
  441. app/db/session.py +1295 -0
  442. app/db/snapshot.py +46 -0
  443. app/db/sqlite_lock_retry.py +190 -0
  444. app/db/sqlite_utils.py +549 -0
  445. app/dependencies.py +462 -0
  446. app/main.py +1240 -0
  447. app/modules/__init__.py +0 -0
  448. app/modules/accounts/__init__.py +0 -0
  449. app/modules/accounts/api.py +426 -0
  450. app/modules/accounts/auth_manager.py +1439 -0
  451. app/modules/accounts/background_repository.py +160 -0
  452. app/modules/accounts/deletion.py +540 -0
  453. app/modules/accounts/mappers.py +654 -0
  454. app/modules/accounts/refresh_claims.py +352 -0
  455. app/modules/accounts/repository.py +1679 -0
  456. app/modules/accounts/schemas.py +308 -0
  457. app/modules/accounts/service.py +809 -0
  458. app/modules/accounts/usage_rollup.py +486 -0
  459. app/modules/accounts/usage_rollup_scheduler.py +75 -0
  460. app/modules/accounts/usage_time_rollup.py +1037 -0
  461. app/modules/accounts/usage_time_rollup_read.py +522 -0
  462. app/modules/api_keys/__init__.py +1 -0
  463. app/modules/api_keys/api.py +344 -0
  464. app/modules/api_keys/last_used_coalescer.py +216 -0
  465. app/modules/api_keys/limit_windows.py +41 -0
  466. app/modules/api_keys/repository.py +1200 -0
  467. app/modules/api_keys/reset_scheduler.py +85 -0
  468. app/modules/api_keys/schemas.py +135 -0
  469. app/modules/api_keys/service.py +2123 -0
  470. app/modules/audit/__init__.py +0 -0
  471. app/modules/audit/api.py +88 -0
  472. app/modules/audit/repository.py +74 -0
  473. app/modules/audit/schemas.py +33 -0
  474. app/modules/audit/service.py +71 -0
  475. app/modules/auth_providers/__init__.py +0 -0
  476. app/modules/auth_providers/api.py +127 -0
  477. app/modules/auth_providers/config.py +343 -0
  478. app/modules/auth_providers/repository.py +101 -0
  479. app/modules/auth_providers/schemas.py +88 -0
  480. app/modules/auth_providers/seed.py +105 -0
  481. app/modules/auth_providers/service.py +289 -0
  482. app/modules/automations/__init__.py +1 -0
  483. app/modules/automations/api.py +369 -0
  484. app/modules/automations/repository.py +1896 -0
  485. app/modules/automations/scheduler.py +107 -0
  486. app/modules/automations/schemas.py +158 -0
  487. app/modules/automations/service.py +2548 -0
  488. app/modules/cache_isolation_probe/__init__.py +0 -0
  489. app/modules/cache_isolation_probe/api.py +188 -0
  490. app/modules/cache_isolation_probe/prefix.py +144 -0
  491. app/modules/cache_isolation_probe/schemas.py +94 -0
  492. app/modules/cache_isolation_probe/sender.py +229 -0
  493. app/modules/cache_isolation_probe/service.py +525 -0
  494. app/modules/conversation_archive/__init__.py +1 -0
  495. app/modules/conversation_archive/api.py +101 -0
  496. app/modules/conversation_archive/schemas.py +28 -0
  497. app/modules/conversation_archive/service.py +165 -0
  498. app/modules/dashboard/__init__.py +1 -0
  499. app/modules/dashboard/api.py +106 -0
  500. app/modules/dashboard/builders.py +107 -0
  501. app/modules/dashboard/repository.py +304 -0
  502. app/modules/dashboard/schemas.py +148 -0
  503. app/modules/dashboard/service.py +534 -0
  504. app/modules/dashboard/timeframes.py +19 -0
  505. app/modules/dashboard/weekly_pace.py +617 -0
  506. app/modules/dashboard_auth/__init__.py +1 -0
  507. app/modules/dashboard_auth/api.py +1344 -0
  508. app/modules/dashboard_auth/oidc_api.py +753 -0
  509. app/modules/dashboard_auth/oidc_flows.py +330 -0
  510. app/modules/dashboard_auth/repository.py +452 -0
  511. app/modules/dashboard_auth/schemas.py +232 -0
  512. app/modules/dashboard_auth/service.py +1384 -0
  513. app/modules/dashboard_roles/__init__.py +0 -0
  514. app/modules/dashboard_roles/api.py +64 -0
  515. app/modules/dashboard_roles/repository.py +54 -0
  516. app/modules/dashboard_roles/schemas.py +29 -0
  517. app/modules/dashboard_roles/seed.py +80 -0
  518. app/modules/dashboard_roles/service.py +142 -0
  519. app/modules/dashboard_users/__init__.py +0 -0
  520. app/modules/dashboard_users/api.py +278 -0
  521. app/modules/dashboard_users/break_glass.py +169 -0
  522. app/modules/dashboard_users/credentials.py +18 -0
  523. app/modules/dashboard_users/identity_resolver.py +665 -0
  524. app/modules/dashboard_users/repository.py +725 -0
  525. app/modules/dashboard_users/schemas.py +117 -0
  526. app/modules/dashboard_users/service.py +1054 -0
  527. app/modules/firewall/__init__.py +1 -0
  528. app/modules/firewall/api.py +78 -0
  529. app/modules/firewall/repository.py +51 -0
  530. app/modules/firewall/schemas.py +28 -0
  531. app/modules/firewall/service.py +93 -0
  532. app/modules/fleet/__init__.py +1 -0
  533. app/modules/fleet/api.py +177 -0
  534. app/modules/fleet/mappers.py +56 -0
  535. app/modules/fleet/observability.py +372 -0
  536. app/modules/fleet/schemas.py +119 -0
  537. app/modules/health/__init__.py +0 -0
  538. app/modules/health/api.py +246 -0
  539. app/modules/health/schemas.py +27 -0
  540. app/modules/limit_warmup/__init__.py +1 -0
  541. app/modules/limit_warmup/repository.py +159 -0
  542. app/modules/limit_warmup/service.py +1001 -0
  543. app/modules/model_sources/__init__.py +1 -0
  544. app/modules/model_sources/api.py +99 -0
  545. app/modules/model_sources/catalog.py +307 -0
  546. app/modules/model_sources/forwarding.py +1435 -0
  547. app/modules/model_sources/projection.py +55 -0
  548. app/modules/model_sources/repository.py +201 -0
  549. app/modules/model_sources/schemas.py +79 -0
  550. app/modules/model_sources/selection.py +166 -0
  551. app/modules/model_sources/service.py +236 -0
  552. app/modules/oauth/__init__.py +0 -0
  553. app/modules/oauth/api.py +101 -0
  554. app/modules/oauth/repository.py +238 -0
  555. app/modules/oauth/schemas.py +49 -0
  556. app/modules/oauth/service.py +1196 -0
  557. app/modules/oauth/templates/oauth_success.html +122 -0
  558. app/modules/proxy/__init__.py +0 -0
  559. app/modules/proxy/_load_balancer/__init__.py +1 -0
  560. app/modules/proxy/_load_balancer/error_rate.py +116 -0
  561. app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
  562. app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
  563. app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
  564. app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
  565. app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
  566. app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
  567. app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
  568. app/modules/proxy/_load_balancer/tunables.py +97 -0
  569. app/modules/proxy/_load_balancer/types.py +96 -0
  570. app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
  571. app/modules/proxy/_service/__init__.py +6 -0
  572. app/modules/proxy/_service/api_key_usage.py +745 -0
  573. app/modules/proxy/_service/clock_budget.py +38 -0
  574. app/modules/proxy/_service/codex_control.py +611 -0
  575. app/modules/proxy/_service/compact.py +2211 -0
  576. app/modules/proxy/_service/file_ops.py +749 -0
  577. app/modules/proxy/_service/http_bridge/__init__.py +3 -0
  578. app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
  579. app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
  580. app/modules/proxy/_service/http_bridge/activity.py +178 -0
  581. app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
  582. app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
  583. app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
  584. app/modules/proxy/_service/http_bridge/protocol.py +167 -0
  585. app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
  586. app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
  587. app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
  588. app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
  589. app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
  590. app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
  591. app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
  592. app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
  593. app/modules/proxy/_service/observability.py +410 -0
  594. app/modules/proxy/_service/rate_limit.py +361 -0
  595. app/modules/proxy/_service/realtime_live.py +655 -0
  596. app/modules/proxy/_service/refresh.py +154 -0
  597. app/modules/proxy/_service/request_log.py +585 -0
  598. app/modules/proxy/_service/response_create.py +930 -0
  599. app/modules/proxy/_service/streaming/__init__.py +3 -0
  600. app/modules/proxy/_service/streaming/helpers.py +1317 -0
  601. app/modules/proxy/_service/streaming/mixin.py +1100 -0
  602. app/modules/proxy/_service/streaming/protocol.py +32 -0
  603. app/modules/proxy/_service/streaming/retry.py +3762 -0
  604. app/modules/proxy/_service/support.py +2122 -0
  605. app/modules/proxy/_service/transcribe.py +434 -0
  606. app/modules/proxy/_service/warmup.py +483 -0
  607. app/modules/proxy/_service/websocket/__init__.py +3 -0
  608. app/modules/proxy/_service/websocket/helpers.py +2360 -0
  609. app/modules/proxy/_service/websocket/mixin.py +7285 -0
  610. app/modules/proxy/_service/websocket/protocol.py +74 -0
  611. app/modules/proxy/account_cache.py +229 -0
  612. app/modules/proxy/account_eligibility.py +87 -0
  613. app/modules/proxy/additional_model_limits.py +40 -0
  614. app/modules/proxy/affinity.py +830 -0
  615. app/modules/proxy/affinity_observation.py +101 -0
  616. app/modules/proxy/api.py +10254 -0
  617. app/modules/proxy/api_key_usage.py +113 -0
  618. app/modules/proxy/cap_partitioning.py +280 -0
  619. app/modules/proxy/capability_lineage.py +65 -0
  620. app/modules/proxy/capability_lineage_repository.py +82 -0
  621. app/modules/proxy/capability_routing.py +253 -0
  622. app/modules/proxy/continuity.py +112 -0
  623. app/modules/proxy/downstream_delivery.py +277 -0
  624. app/modules/proxy/durable_bridge_coordinator.py +1217 -0
  625. app/modules/proxy/durable_bridge_repository.py +4516 -0
  626. app/modules/proxy/durable_bridge_runtime.py +9 -0
  627. app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
  628. app/modules/proxy/fair_share.py +131 -0
  629. app/modules/proxy/file_pin_repository.py +210 -0
  630. app/modules/proxy/helpers.py +439 -0
  631. app/modules/proxy/http_bridge_event_batcher.py +613 -0
  632. app/modules/proxy/http_bridge_forwarding.py +797 -0
  633. app/modules/proxy/http_continuation.py +76 -0
  634. app/modules/proxy/images_observability.py +62 -0
  635. app/modules/proxy/images_service.py +1054 -0
  636. app/modules/proxy/load_balancer.py +3016 -0
  637. app/modules/proxy/rate_limit_cache.py +46 -0
  638. app/modules/proxy/replay_safety.py +1063 -0
  639. app/modules/proxy/repo_bundle.py +31 -0
  640. app/modules/proxy/request_policy.py +1054 -0
  641. app/modules/proxy/ring_membership.py +251 -0
  642. app/modules/proxy/schemas.py +345 -0
  643. app/modules/proxy/selection_errors.py +41 -0
  644. app/modules/proxy/service.py +2600 -0
  645. app/modules/proxy/source_admission.py +125 -0
  646. app/modules/proxy/source_dispatch.py +999 -0
  647. app/modules/proxy/sticky_repository.py +831 -0
  648. app/modules/proxy/thread_anchors.py +491 -0
  649. app/modules/proxy/tool_call_dedupe.py +802 -0
  650. app/modules/proxy/types.py +54 -0
  651. app/modules/proxy/work_admission.py +121 -0
  652. app/modules/quota_planner/__init__.py +1 -0
  653. app/modules/quota_planner/api.py +276 -0
  654. app/modules/quota_planner/logic.py +743 -0
  655. app/modules/quota_planner/repository.py +722 -0
  656. app/modules/quota_planner/scheduler.py +212 -0
  657. app/modules/quota_planner/schemas.py +102 -0
  658. app/modules/quota_planner/warmup.py +693 -0
  659. app/modules/rate_limit_reset_credits/__init__.py +0 -0
  660. app/modules/rate_limit_reset_credits/api.py +601 -0
  661. app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
  662. app/modules/rate_limit_reset_credits/store.py +114 -0
  663. app/modules/reports/__init__.py +1 -0
  664. app/modules/reports/api.py +134 -0
  665. app/modules/reports/cache.py +74 -0
  666. app/modules/reports/filters.py +27 -0
  667. app/modules/reports/repository.py +525 -0
  668. app/modules/reports/rollup.py +150 -0
  669. app/modules/reports/rollup_read.py +148 -0
  670. app/modules/reports/schemas.py +123 -0
  671. app/modules/reports/service.py +287 -0
  672. app/modules/reports/thread_identity.py +256 -0
  673. app/modules/request_logs/__init__.py +0 -0
  674. app/modules/request_logs/api.py +224 -0
  675. app/modules/request_logs/cost_backfill.py +154 -0
  676. app/modules/request_logs/mappers.py +97 -0
  677. app/modules/request_logs/repository.py +1765 -0
  678. app/modules/request_logs/schemas.py +145 -0
  679. app/modules/request_logs/service.py +348 -0
  680. app/modules/role_mappings/__init__.py +0 -0
  681. app/modules/role_mappings/api.py +206 -0
  682. app/modules/role_mappings/matching.py +55 -0
  683. app/modules/role_mappings/repository.py +106 -0
  684. app/modules/role_mappings/schemas.py +67 -0
  685. app/modules/role_mappings/service.py +334 -0
  686. app/modules/runtime/__init__.py +1 -0
  687. app/modules/runtime/api.py +18 -0
  688. app/modules/runtime/schemas.py +14 -0
  689. app/modules/runtime/service.py +218 -0
  690. app/modules/scim/__init__.py +1 -0
  691. app/modules/scim/api.py +194 -0
  692. app/modules/scim/dependencies.py +112 -0
  693. app/modules/scim/errors.py +77 -0
  694. app/modules/scim/management_api.py +156 -0
  695. app/modules/scim/repository.py +212 -0
  696. app/modules/scim/schemas.py +145 -0
  697. app/modules/scim/service.py +786 -0
  698. app/modules/scim/tokens.py +43 -0
  699. app/modules/settings/__init__.py +1 -0
  700. app/modules/settings/api.py +1677 -0
  701. app/modules/settings/repository.py +606 -0
  702. app/modules/settings/schemas.py +496 -0
  703. app/modules/settings/service.py +769 -0
  704. app/modules/shared/__init__.py +0 -0
  705. app/modules/shared/schemas.py +22 -0
  706. app/modules/sticky_sessions/__init__.py +1 -0
  707. app/modules/sticky_sessions/api.py +118 -0
  708. app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
  709. app/modules/sticky_sessions/schemas.py +78 -0
  710. app/modules/sticky_sessions/service.py +196 -0
  711. app/modules/telemetry/__init__.py +5 -0
  712. app/modules/telemetry/api.py +115 -0
  713. app/modules/telemetry/clients.py +62 -0
  714. app/modules/telemetry/consent.py +115 -0
  715. app/modules/telemetry/scheduler.py +85 -0
  716. app/modules/telemetry/schemas.py +163 -0
  717. app/modules/telemetry/sender.py +221 -0
  718. app/modules/telemetry/snapshot.py +490 -0
  719. app/modules/usage/__init__.py +0 -0
  720. app/modules/usage/additional_quota_keys.py +379 -0
  721. app/modules/usage/api.py +45 -0
  722. app/modules/usage/background_repository.py +147 -0
  723. app/modules/usage/builders.py +559 -0
  724. app/modules/usage/depletion_service.py +277 -0
  725. app/modules/usage/live_ingest.py +356 -0
  726. app/modules/usage/mappers.py +29 -0
  727. app/modules/usage/plan_downgrade_observations.py +488 -0
  728. app/modules/usage/repository.py +1890 -0
  729. app/modules/usage/schemas.py +68 -0
  730. app/modules/usage/service.py +102 -0
  731. app/modules/usage/updater.py +1488 -0
  732. app/static/assets/Trans-Cj-J9mVA.js +1 -0
  733. app/static/assets/access-page-DHzZiXZi.js +1 -0
  734. app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
  735. app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
  736. app/static/assets/account-status-DIsU5sUS.js +1 -0
  737. app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
  738. app/static/assets/accounts-page-Bbai8ift.js +3 -0
  739. app/static/assets/api-Dwa7XxPe.js +1 -0
  740. app/static/assets/api-client-dugX1Pti.js +55 -0
  741. app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
  742. app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
  743. app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
  744. app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
  745. app/static/assets/apis-page-BvXz9c6i.js +2 -0
  746. app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
  747. app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
  748. app/static/assets/automations-page-DQgNAJOR.js +3 -0
  749. app/static/assets/badge-DqMb9es0.js +1 -0
  750. app/static/assets/blur-email-HEvKagG2.js +1 -0
  751. app/static/assets/button-BjVmmiGW.js +1 -0
  752. app/static/assets/checkbox-apsl9vzk.js +1 -0
  753. app/static/assets/chevron-left-1d_pHVa5.js +1 -0
  754. app/static/assets/circle-alert-BS0xd_o1.js +1 -0
  755. app/static/assets/clsx-DB0hHKMi.js +1 -0
  756. app/static/assets/colors-BBy8GEtr.js +1 -0
  757. app/static/assets/copy-button-D7nAvOKR.js +1 -0
  758. app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
  759. app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
  760. app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
  761. app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
  762. app/static/assets/donut-chart-Bxc69cra.js +1 -0
  763. app/static/assets/download-kQteIcsB.js +1 -0
  764. app/static/assets/ellipsis-B_5OgIre.js +1 -0
  765. app/static/assets/empty-state-JcA7tc8m.js +1 -0
  766. app/static/assets/es2015-bMH8YKZT.js +41 -0
  767. app/static/assets/es6-B264ni6W.js +69 -0
  768. app/static/assets/external-link-UVAOgMQ0.js +1 -0
  769. app/static/assets/i18n-BWqZ9Ua2.js +1 -0
  770. app/static/assets/inbox-B_RM2s1J.js +1 -0
  771. app/static/assets/index-DIpd264Z.js +23 -0
  772. app/static/assets/index-DMeLKRJx.css +2 -0
  773. app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
  774. app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
  775. app/static/assets/iso-Bpe5Ihbb.js +1 -0
  776. app/static/assets/label-CGaLmLNf.js +1 -0
  777. app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
  778. app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
  779. app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
  780. app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
  781. app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
  782. app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
  783. app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
  784. app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
  785. app/static/assets/payload-CIYnGZz9.js +1 -0
  786. app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
  787. app/static/assets/report-chart-card-BXDliwcc.js +1 -0
  788. app/static/assets/reports-page-CGCJeMHG.js +3 -0
  789. app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
  790. app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  791. app/static/assets/search-HfhqwERt.js +1 -0
  792. app/static/assets/select-Cl33bz-g.js +1 -0
  793. app/static/assets/settings-page-mZj01HYl.js +2 -0
  794. app/static/assets/shim-CZwyM4Q7.js +1 -0
  795. app/static/assets/skeleton-C_EUc4Rp.js +1 -0
  796. app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
  797. app/static/assets/status-badge-CHHA5X6c.js +1 -0
  798. app/static/assets/switch-CzxgokcM.js +1 -0
  799. app/static/assets/table-DXaNSQ0G.js +1 -0
  800. app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
  801. app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
  802. app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
  803. app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
  804. app/static/assets/trash-D3KyWr1h.js +1 -0
  805. app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
  806. app/static/assets/use-accounts-PWlVGw3z.js +1 -0
  807. app/static/assets/use-auth-CuDlW3iC.js +1 -0
  808. app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
  809. app/static/assets/use-model-sources-Bul24JBu.js +1 -0
  810. app/static/assets/use-models-DUCPUuZg.js +1 -0
  811. app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
  812. app/static/assets/useTranslation--56nHhlH.js +1 -0
  813. app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
  814. app/static/assets/users-uIC3TIlE.js +1 -0
  815. app/static/assets/vendor-query-_bHZuxEx.js +1 -0
  816. app/static/assets/vendor-react-BVL0YZPW.js +9 -0
  817. app/static/favicon.svg +10 -0
  818. app/static/fonts/GeistSans-Variable.woff2 +0 -0
  819. app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  820. app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  821. app/static/index.html +29 -0
  822. codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
  823. codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
  824. codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
  825. codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
  826. codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
  827. config/additional_quota_registry.json +11 -0
@@ -0,0 +1,1677 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import socket
5
+ import time
6
+ from collections.abc import Mapping
7
+ from typing import Any, cast
8
+
9
+ import aiohttp
10
+ import httpx
11
+ from aiohttp_socks import ProxyConnector
12
+ from fastapi import APIRouter, Body, Depends, Request
13
+ from python_socks import ProxyType
14
+ from sqlalchemy import select
15
+ from sqlalchemy.exc import IntegrityError
16
+
17
+ from app.core.audit.service import AuditActor, AuditService, AuditSeverity, AuditTarget
18
+ from app.core.auth.dashboard_access import DashboardPrincipal, DashboardRole, Permission
19
+ from app.core.auth.dependencies import (
20
+ ensure_dashboard_permission,
21
+ ensure_step_up,
22
+ require_dashboard_permission,
23
+ require_dashboard_write_access,
24
+ set_dashboard_error_format,
25
+ validate_dashboard_session,
26
+ )
27
+ from app.core.clients.http import _shared_ssl_context
28
+ from app.core.config import settings as settings_module
29
+ from app.core.config.background_jobs import BACKGROUND_JOB_SETTINGS
30
+ from app.core.config.context_window_overrides import (
31
+ get_model_context_window_overrides_cache,
32
+ resolve_context_window_overrides,
33
+ )
34
+ from app.core.config.dashboard_overrides import DASHBOARD_TIMEOUT_SETTINGS
35
+ from app.core.config.inheritable import resolve_inheritable
36
+ from app.core.config.settings import get_settings as get_app_settings
37
+ from app.core.config.settings_cache import get_settings_cache
38
+ from app.core.config.spool_retention import (
39
+ OPERATION_SPOOL_RETENTION_SETTING,
40
+ SPOOL_RETENTION_FLOOR_INPUTS,
41
+ binding_spool_retention_floor_term,
42
+ operation_spool_retention_floor_seconds,
43
+ )
44
+ from app.core.conversation_archive import CONVERSATION_ARCHIVE_SETTING, CONVERSATION_ARCHIVE_TOGGLED_ACTION
45
+ from app.core.crypto import TokenEncryptor
46
+ from app.core.exceptions import (
47
+ DashboardBadRequestError,
48
+ DashboardConflictError,
49
+ DashboardNotFoundError,
50
+ DashboardSettingsConflictError,
51
+ )
52
+ from app.core.resilience.toggles import RESILIENCE_TOGGLE_SETTINGS
53
+ from app.core.timeout_invariants import find_timeout_invariant_violations
54
+ from app.core.upstream_proxy import UpstreamProxyRouteError, resolve_proxy_endpoint, sends_plaintext_credentials
55
+ from app.core.upstream_proxy.cache import get_upstream_route_cache
56
+ from app.db.models import Account, AccountProxyBinding, AccountStatus, ProxyEndpoint, ProxyPool, ProxyPoolMember
57
+ from app.dependencies import SettingsContext, get_proxy_service_for_app, get_settings_context
58
+ from app.modules.dashboard_users.break_glass import BreakGlassRequiresTotpError
59
+ from app.modules.proxy.account_cache import (
60
+ clear_account_routing_unavailable,
61
+ get_account_selection_cache,
62
+ propagate_account_routing_change,
63
+ )
64
+ from app.modules.settings.repository import ModelContextWindowOverridesRepository
65
+ from app.modules.settings.schemas import (
66
+ SECURITY_SETTINGS_FIELDS,
67
+ AccountProxyBindingRequest,
68
+ AccountProxyBindingResponse,
69
+ AdditionalQuotaPolicy,
70
+ DashboardSettingsResponse,
71
+ DashboardSettingsUpdateRequest,
72
+ LocalLoginPolicyLiteral,
73
+ ModelContextWindowOverrideResponse,
74
+ ModelContextWindowOverridesResponse,
75
+ ModelContextWindowOverrideUpsertRequest,
76
+ RuntimeConnectAddressResponse,
77
+ SettingProvenance,
78
+ UpstreamProxyAdminResponse,
79
+ UpstreamProxyEndpointCreateRequest,
80
+ UpstreamProxyEndpointResponse,
81
+ UpstreamProxyEndpointTestResponse,
82
+ UpstreamProxyPoolCreateRequest,
83
+ UpstreamProxyPoolMemberRequest,
84
+ UpstreamProxyPoolResponse,
85
+ )
86
+ from app.modules.settings.service import DashboardSettingsUpdateData
87
+ from app.modules.usage.additional_quota_keys import (
88
+ get_additional_quota_routing_policy,
89
+ list_additional_quota_definitions,
90
+ )
91
+
92
+ LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "[::1]"}
93
+ UPSTREAM_PROXY_TEST_URL = "https://chatgpt.com/cdn-cgi/trace"
94
+ UPSTREAM_PROXY_TEST_TIMEOUT_SECONDS = 8.0
95
+ HTTP_PROXY_AUTHENTICATION_REQUIRED = 407
96
+ IMPORT_PROXY_REQUIRED_PAUSE_REASON = "upstream_proxy_required_on_import"
97
+
98
+
99
+ def _is_non_loopback_ipv4(value: str | None) -> bool:
100
+ if not value:
101
+ return False
102
+ try:
103
+ address = ipaddress.ip_address(value)
104
+ except ValueError:
105
+ return False
106
+ return isinstance(address, ipaddress.IPv4Address) and not address.is_loopback and not address.is_unspecified
107
+
108
+
109
+ def _resolve_hostname_ipv4(hostname: str) -> str | None:
110
+ try:
111
+ infos = socket.getaddrinfo(hostname, None, family=socket.AF_INET, type=socket.SOCK_STREAM)
112
+ except OSError:
113
+ return None
114
+ for info in infos:
115
+ candidate = info[4][0]
116
+ if not isinstance(candidate, str):
117
+ continue
118
+ if _is_non_loopback_ipv4(candidate):
119
+ return candidate
120
+ return None
121
+
122
+
123
+ def _resolve_runtime_connect_address(request: Request) -> str:
124
+ override = get_app_settings().connect_address
125
+ if override:
126
+ return override
127
+
128
+ request_host = request.url.hostname or ""
129
+ if _is_non_loopback_ipv4(request_host):
130
+ return request_host
131
+
132
+ normalized_host = request_host.strip().lower()
133
+ if normalized_host and normalized_host not in LOOPBACK_HOSTS:
134
+ resolved_host = _resolve_hostname_ipv4(request_host)
135
+ if resolved_host:
136
+ return resolved_host
137
+ return request_host
138
+ return "<codex-lb-ip-or-dns>"
139
+
140
+
141
+ router = APIRouter(
142
+ prefix="/api/settings",
143
+ tags=["dashboard"],
144
+ dependencies=[Depends(validate_dashboard_session), Depends(set_dashboard_error_format)],
145
+ )
146
+
147
+
148
+ # C2-2 routing/overload
149
+ _ROUTING_OVERLOAD_SETTINGS: tuple[str, ...] = (
150
+ "proxy_overload_isolation_seconds",
151
+ "proxy_account_error_rate_weighting_enabled",
152
+ "proxy_account_inflight_penalty_pct",
153
+ "proxy_account_lease_token_weight",
154
+ "proxy_account_lease_ttl_seconds",
155
+ )
156
+
157
+
158
+ def _dashboard_value(payload: DashboardSettingsUpdateRequest, name: str) -> Any:
159
+ """Tri-state read: the value when the field was sent (``None`` for an explicit
160
+ null), ``None`` when it was omitted (unchanged)."""
161
+ return getattr(payload, name) if name in payload.model_fields_set else None
162
+
163
+
164
+ def _clears_dashboard_value(payload: DashboardSettingsUpdateRequest, name: str) -> bool:
165
+ """True only for an explicit ``null``: the setting returns to inheriting the
166
+ environment value or code default."""
167
+ return name in payload.model_fields_set and getattr(payload, name) is None
168
+
169
+
170
+ # end C2-2 routing/overload
171
+
172
+
173
+ def _dashboard_settings_response(settings, *, principal: DashboardPrincipal) -> DashboardSettingsResponse:
174
+ environment_settings = get_app_settings()
175
+ # M5 conversation archive: the T1 directory is this replica's local shard;
176
+ # a filesystem path is admin-only information. ``getattr``: startup-settings
177
+ # fakes in tests may carry only the fields they exercise.
178
+ archive_dir = getattr(environment_settings, "conversation_archive_dir", None)
179
+ conversation_archive_dir = (
180
+ str(archive_dir) if archive_dir is not None and principal.role == DashboardRole.ADMIN else None
181
+ )
182
+ additional_quota_policies = [
183
+ AdditionalQuotaPolicy(
184
+ quota_key=definition.quota_key,
185
+ display_label=definition.display_label,
186
+ routing_policy=get_additional_quota_routing_policy(
187
+ definition.quota_key,
188
+ overrides=settings.additional_quota_routing_policies,
189
+ ),
190
+ model_ids=sorted(definition.model_ids),
191
+ )
192
+ for definition in list_additional_quota_definitions()
193
+ ]
194
+ return DashboardSettingsResponse(
195
+ sticky_threads_enabled=settings.sticky_threads_enabled,
196
+ upstream_stream_transport=settings.upstream_stream_transport,
197
+ prohibit_fast_mode=settings.prohibit_fast_mode,
198
+ http_downstream_transport_policy=settings.http_downstream_transport_policy,
199
+ thread_cache_identity_mode=settings.thread_cache_identity_mode,
200
+ thread_cache_identity_mode_override=settings.thread_cache_identity_mode_override,
201
+ proxy_account_response_create_limit=settings.proxy_account_response_create_limit,
202
+ proxy_account_response_create_limit_environment_value=(
203
+ getattr(
204
+ environment_settings,
205
+ "proxy_account_response_create_limit",
206
+ settings.proxy_account_response_create_limit,
207
+ )
208
+ ),
209
+ proxy_account_response_create_limit_override=(settings.proxy_account_response_create_limit_override),
210
+ proxy_account_stream_limit=settings.proxy_account_stream_limit,
211
+ proxy_account_stream_limit_environment_value=getattr(
212
+ environment_settings,
213
+ "proxy_account_stream_limit",
214
+ settings.proxy_account_stream_limit,
215
+ ),
216
+ proxy_account_stream_limit_override=settings.proxy_account_stream_limit_override,
217
+ proxy_account_stream_recovery_reserve=settings.proxy_account_stream_recovery_reserve,
218
+ proxy_account_stream_recovery_reserve_environment_value=(
219
+ getattr(
220
+ environment_settings,
221
+ "proxy_account_stream_recovery_reserve",
222
+ settings.proxy_account_stream_recovery_reserve,
223
+ )
224
+ ),
225
+ proxy_account_stream_recovery_reserve_override=(settings.proxy_account_stream_recovery_reserve_override),
226
+ proxy_api_key_fair_share_congestion_threshold_pct=(settings.proxy_api_key_fair_share_congestion_threshold_pct),
227
+ proxy_api_key_fair_share_congestion_threshold_pct_environment_value=(
228
+ getattr(
229
+ environment_settings,
230
+ "proxy_api_key_fair_share_congestion_threshold_pct",
231
+ settings.proxy_api_key_fair_share_congestion_threshold_pct,
232
+ )
233
+ ),
234
+ proxy_api_key_fair_share_congestion_threshold_pct_override=(
235
+ settings.proxy_api_key_fair_share_congestion_threshold_pct_override
236
+ ),
237
+ # C2-2 routing/overload
238
+ proxy_overload_isolation_seconds=settings.proxy_overload_isolation_seconds,
239
+ proxy_account_error_rate_weighting_enabled=settings.proxy_account_error_rate_weighting_enabled,
240
+ proxy_account_inflight_penalty_pct=settings.proxy_account_inflight_penalty_pct,
241
+ proxy_account_lease_token_weight=settings.proxy_account_lease_token_weight,
242
+ proxy_account_lease_ttl_seconds=settings.proxy_account_lease_ttl_seconds,
243
+ # end C2-2 routing/overload
244
+ upstream_proxy_routing_enabled=settings.upstream_proxy_routing_enabled,
245
+ upstream_proxy_default_pool_id=settings.upstream_proxy_default_pool_id,
246
+ prefer_earlier_reset_accounts=settings.prefer_earlier_reset_accounts,
247
+ prefer_earlier_reset_window=settings.prefer_earlier_reset_window,
248
+ show_reset_credit_badges=settings.show_reset_credit_badges,
249
+ auto_redeem_reset_credits_before_expiry=settings.auto_redeem_reset_credits_before_expiry,
250
+ show_reset_credit_expiry_badge=settings.show_reset_credit_expiry_badge,
251
+ routing_strategy=settings.routing_strategy,
252
+ relative_availability_power=settings.relative_availability_power,
253
+ relative_availability_top_k=settings.relative_availability_top_k,
254
+ single_account_id=settings.single_account_id,
255
+ openai_cache_affinity_max_age_seconds=settings.openai_cache_affinity_max_age_seconds,
256
+ dashboard_session_ttl_seconds=settings.dashboard_session_ttl_seconds,
257
+ http_responses_session_bridge_prompt_cache_idle_ttl_seconds=settings.http_responses_session_bridge_prompt_cache_idle_ttl_seconds,
258
+ http_responses_session_bridge_gateway_safe_mode=settings.http_responses_session_bridge_gateway_safe_mode,
259
+ # M3 codex prewarm
260
+ http_responses_session_bridge_codex_prewarm_enabled=(
261
+ settings.http_responses_session_bridge_codex_prewarm_enabled
262
+ ),
263
+ # end M3 codex prewarm
264
+ sticky_reallocation_budget_threshold_pct=settings.sticky_reallocation_budget_threshold_pct,
265
+ sticky_reallocation_primary_budget_threshold_pct=settings.sticky_reallocation_primary_budget_threshold_pct,
266
+ sticky_reallocation_secondary_budget_threshold_pct=settings.sticky_reallocation_secondary_budget_threshold_pct,
267
+ additional_quota_routing_policies=settings.additional_quota_routing_policies,
268
+ additional_quota_policies=additional_quota_policies,
269
+ warmup_model=settings.warmup_model,
270
+ import_without_overwrite=settings.import_without_overwrite,
271
+ totp_required_on_login=settings.totp_required_on_login,
272
+ totp_required_for_admin_role=settings.totp_required_for_admin_role,
273
+ local_login_policy=cast(LocalLoginPolicyLiteral, settings.local_login_policy),
274
+ users_without_totp_count=settings.users_without_totp_count,
275
+ admins_without_totp_count=settings.admins_without_totp_count,
276
+ api_key_auth_enabled=settings.api_key_auth_enabled,
277
+ hide_upstream_quota_from_api_keys=settings.hide_upstream_quota_from_api_keys,
278
+ limit_warmup_enabled=settings.limit_warmup_enabled,
279
+ limit_warmup_windows=settings.limit_warmup_windows,
280
+ limit_warmup_model=settings.limit_warmup_model,
281
+ limit_warmup_prompt=settings.limit_warmup_prompt,
282
+ limit_warmup_cooldown_seconds=settings.limit_warmup_cooldown_seconds,
283
+ limit_warmup_exhausted_threshold_percent=settings.limit_warmup_exhausted_threshold_percent,
284
+ limit_warmup_idle_threshold_percent=settings.limit_warmup_idle_threshold_percent,
285
+ limit_warmup_min_available_percent=settings.limit_warmup_min_available_percent,
286
+ weekly_pace_working_days=settings.weekly_pace_working_days,
287
+ weekly_pace_smoothing_minutes=settings.weekly_pace_smoothing_minutes,
288
+ guest_access_enabled=settings.guest_access_enabled,
289
+ guest_password_configured=settings.guest_password_configured,
290
+ limit_warmup_staggered_idle_enabled=settings.limit_warmup_staggered_idle_enabled,
291
+ request_log_retention_days=settings.request_log_retention_days,
292
+ usage_history_retention_days=settings.usage_history_retention_days,
293
+ request_log_retention_override_days=settings.request_log_retention_override_days,
294
+ usage_history_retention_override_days=settings.usage_history_retention_override_days,
295
+ # C2-3 resilience toggles
296
+ soft_drain_enabled=settings.soft_drain_enabled,
297
+ deterministic_failover_enabled=settings.deterministic_failover_enabled,
298
+ circuit_breaker_enabled=settings.circuit_breaker_enabled,
299
+ # M2 background jobs
300
+ auth_guardian_enabled=settings.auth_guardian_enabled,
301
+ auth_guardian_blocked_by_topology=settings.auth_guardian_blocked_by_topology,
302
+ automations_scheduler_enabled=settings.automations_scheduler_enabled,
303
+ rate_limit_reset_credits_refresh_enabled=settings.rate_limit_reset_credits_refresh_enabled,
304
+ # end M2 background jobs
305
+ # M5 conversation archive
306
+ conversation_archive_enabled=settings.conversation_archive_enabled,
307
+ conversation_archive_dir=conversation_archive_dir,
308
+ # end M5 conversation archive
309
+ # R2 spool retention: the effective window plus the floor the API would
310
+ # enforce, so the data retention card can mirror the check client-side.
311
+ http_responses_session_bridge_operation_spool_retention_seconds=(
312
+ settings.http_responses_session_bridge_operation_spool_retention_seconds
313
+ ),
314
+ http_responses_session_bridge_operation_spool_retention_floor_seconds=(
315
+ operation_spool_retention_floor_seconds(settings, startup_settings=environment_settings)
316
+ ),
317
+ # end R2 spool retention
318
+ version=settings.version,
319
+ # C2-1 timeouts
320
+ upstream_connect_timeout_seconds=settings.upstream_connect_timeout_seconds,
321
+ proxy_request_budget_seconds=settings.proxy_request_budget_seconds,
322
+ compact_request_budget_seconds=settings.compact_request_budget_seconds,
323
+ transcription_request_budget_seconds=settings.transcription_request_budget_seconds,
324
+ stream_idle_timeout_seconds=settings.stream_idle_timeout_seconds,
325
+ proxy_downstream_websocket_idle_timeout_seconds=settings.proxy_downstream_websocket_idle_timeout_seconds,
326
+ sse_keepalive_interval_seconds=settings.sse_keepalive_interval_seconds,
327
+ # end C2-1 timeouts
328
+ # M1 stream/bridge budgets
329
+ http_responses_stream_request_budget_seconds=settings.http_responses_stream_request_budget_seconds,
330
+ http_responses_session_bridge_request_budget_seconds=(
331
+ settings.http_responses_session_bridge_request_budget_seconds
332
+ ),
333
+ # end M1 stream/bridge budgets
334
+ provenance={
335
+ name: SettingProvenance(source=resolved.source, env_value=resolved.env_value, default=resolved.default)
336
+ for name, resolved in settings.provenance.items()
337
+ },
338
+ )
339
+
340
+
341
+ @router.get("", response_model=DashboardSettingsResponse)
342
+ async def get_settings(
343
+ principal: DashboardPrincipal = Depends(validate_dashboard_session),
344
+ context: SettingsContext = Depends(get_settings_context),
345
+ ) -> DashboardSettingsResponse:
346
+ settings = await context.service.get_settings(actor_user_id=principal.user_id)
347
+ return _dashboard_settings_response(settings, principal=principal)
348
+
349
+
350
+ @router.get(
351
+ "/runtime/connect-address",
352
+ response_model=RuntimeConnectAddressResponse,
353
+ dependencies=[Depends(require_dashboard_permission(Permission.OPS_WRITE))],
354
+ )
355
+ async def get_runtime_connect_address(request: Request) -> RuntimeConnectAddressResponse:
356
+ return RuntimeConnectAddressResponse(connect_address=_resolve_runtime_connect_address(request))
357
+
358
+
359
+ @router.get(
360
+ "/upstream-proxy",
361
+ response_model=UpstreamProxyAdminResponse,
362
+ dependencies=[Depends(require_dashboard_permission(Permission.OPS_WRITE))],
363
+ )
364
+ async def get_upstream_proxy_admin(
365
+ context: SettingsContext = Depends(get_settings_context),
366
+ ) -> UpstreamProxyAdminResponse:
367
+ settings = await context.repository.get_or_create()
368
+ endpoint_rows = (await context.session.execute(select(ProxyEndpoint).order_by(ProxyEndpoint.name.asc()))).scalars()
369
+ pool_rows = (await context.session.execute(select(ProxyPool).order_by(ProxyPool.name.asc()))).scalars().all()
370
+ member_rows = (
371
+ await context.session.execute(select(ProxyPoolMember).order_by(ProxyPoolMember.sort_order.asc()))
372
+ ).scalars()
373
+ bindings = (
374
+ (await context.session.execute(select(AccountProxyBinding).order_by(AccountProxyBinding.account_id.asc())))
375
+ .scalars()
376
+ .all()
377
+ )
378
+ endpoint_ids_by_pool: dict[str, list[str]] = {}
379
+ for member in member_rows:
380
+ endpoint_ids_by_pool.setdefault(member.pool_id, []).append(member.endpoint_id)
381
+ return UpstreamProxyAdminResponse(
382
+ routing_enabled=settings.upstream_proxy_routing_enabled,
383
+ default_pool_id=settings.upstream_proxy_default_pool_id,
384
+ endpoints=[_proxy_endpoint_response(row) for row in endpoint_rows],
385
+ pools=[
386
+ UpstreamProxyPoolResponse(
387
+ id=row.id,
388
+ name=row.name,
389
+ is_active=row.is_active,
390
+ endpoint_ids=endpoint_ids_by_pool.get(row.id, []),
391
+ )
392
+ for row in pool_rows
393
+ ],
394
+ bindings=[
395
+ AccountProxyBindingResponse(account_id=row.account_id, pool_id=row.pool_id, is_active=row.is_active)
396
+ for row in bindings
397
+ ],
398
+ )
399
+
400
+
401
+ @router.post("/upstream-proxy/endpoints", response_model=UpstreamProxyEndpointResponse)
402
+ async def create_upstream_proxy_endpoint(
403
+ payload: UpstreamProxyEndpointCreateRequest,
404
+ _security_access=Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
405
+ context: SettingsContext = Depends(get_settings_context),
406
+ ) -> UpstreamProxyEndpointResponse:
407
+ if payload.username is not None and ":" in payload.username:
408
+ # Mirrors the resolver: a Basic user-id cannot encode a colon.
409
+ raise DashboardBadRequestError('Proxy usernames cannot contain ":"', code="invalid_proxy_username")
410
+ encryptor = TokenEncryptor()
411
+ row = ProxyEndpoint(
412
+ name=payload.name,
413
+ scheme=payload.scheme,
414
+ host=payload.host,
415
+ port=payload.port,
416
+ username=payload.username,
417
+ password_encrypted=encryptor.encrypt(payload.password) if payload.password else None,
418
+ is_active=payload.is_active,
419
+ )
420
+ context.session.add(row)
421
+ await context.session.commit()
422
+ await context.session.refresh(row)
423
+ return _proxy_endpoint_response(row)
424
+
425
+
426
+ @router.post("/upstream-proxy/endpoints/{endpoint_id}/test", response_model=UpstreamProxyEndpointTestResponse)
427
+ async def test_upstream_proxy_endpoint(
428
+ endpoint_id: str,
429
+ _write_access=Depends(require_dashboard_write_access),
430
+ context: SettingsContext = Depends(get_settings_context),
431
+ ) -> UpstreamProxyEndpointTestResponse:
432
+ row = await context.session.get(ProxyEndpoint, endpoint_id)
433
+ if row is None:
434
+ raise DashboardBadRequestError("Proxy endpoint not found", code="proxy_endpoint_not_found")
435
+ try:
436
+ endpoint = resolve_proxy_endpoint(row, encryptor=TokenEncryptor())
437
+ except UpstreamProxyRouteError as exc:
438
+ # Persisted rows the resolver now rejects report the reason instead of 500.
439
+ return UpstreamProxyEndpointTestResponse(endpoint_id=row.id, ok=False, elapsed_ms=0, error=exc.reason)
440
+ started = time.monotonic()
441
+ try:
442
+ status_code = await _probe_upstream_proxy_endpoint(endpoint)
443
+ except Exception as exc:
444
+ return UpstreamProxyEndpointTestResponse(
445
+ endpoint_id=endpoint.id,
446
+ ok=False,
447
+ elapsed_ms=_elapsed_ms(started),
448
+ error=type(exc).__name__,
449
+ )
450
+ if status_code == HTTP_PROXY_AUTHENTICATION_REQUIRED:
451
+ return UpstreamProxyEndpointTestResponse(
452
+ endpoint_id=endpoint.id,
453
+ ok=False,
454
+ status_code=status_code,
455
+ elapsed_ms=_elapsed_ms(started),
456
+ error="proxy_auth_failed",
457
+ )
458
+ ok = status_code < 500
459
+ return UpstreamProxyEndpointTestResponse(
460
+ endpoint_id=endpoint.id,
461
+ ok=ok,
462
+ status_code=status_code,
463
+ elapsed_ms=_elapsed_ms(started),
464
+ error=None if ok else "upstream_probe_failed",
465
+ )
466
+
467
+
468
+ @router.post("/upstream-proxy/pools", response_model=UpstreamProxyPoolResponse)
469
+ async def create_upstream_proxy_pool(
470
+ payload: UpstreamProxyPoolCreateRequest,
471
+ _write_access=Depends(require_dashboard_write_access),
472
+ context: SettingsContext = Depends(get_settings_context),
473
+ ) -> UpstreamProxyPoolResponse:
474
+ endpoint_ids = list(dict.fromkeys(payload.endpoint_ids))
475
+ await _validate_proxy_endpoint_ids(context, endpoint_ids)
476
+ pool = ProxyPool(name=payload.name, is_active=payload.is_active)
477
+ context.session.add(pool)
478
+ await context.session.flush()
479
+ for sort_order, endpoint_id in enumerate(endpoint_ids):
480
+ context.session.add(ProxyPoolMember(pool_id=pool.id, endpoint_id=endpoint_id, sort_order=sort_order))
481
+ try:
482
+ await context.session.commit()
483
+ except IntegrityError as exc:
484
+ await context.session.rollback()
485
+ if _is_missing_proxy_endpoint_error(exc):
486
+ raise DashboardBadRequestError("Proxy endpoint not found", code="proxy_endpoint_not_found")
487
+ raise
488
+ await context.session.refresh(pool)
489
+ return UpstreamProxyPoolResponse(
490
+ id=pool.id,
491
+ name=pool.name,
492
+ is_active=pool.is_active,
493
+ endpoint_ids=endpoint_ids,
494
+ )
495
+
496
+
497
+ @router.post("/upstream-proxy/pools/{pool_id}/members", response_model=UpstreamProxyPoolResponse)
498
+ async def add_upstream_proxy_pool_member(
499
+ pool_id: str,
500
+ payload: UpstreamProxyPoolMemberRequest,
501
+ _write_access=Depends(require_dashboard_write_access),
502
+ context: SettingsContext = Depends(get_settings_context),
503
+ ) -> UpstreamProxyPoolResponse:
504
+ pool = await context.session.get(ProxyPool, pool_id)
505
+ if pool is None:
506
+ raise DashboardBadRequestError("Proxy pool not found", code="proxy_pool_not_found")
507
+ await _validate_proxy_endpoint_ids(context, [payload.endpoint_id])
508
+ await _validate_proxy_pool_member_is_unique(context, pool_id=pool_id, endpoint_id=payload.endpoint_id)
509
+ context.session.add(
510
+ ProxyPoolMember(
511
+ pool_id=pool_id,
512
+ endpoint_id=payload.endpoint_id,
513
+ sort_order=payload.sort_order,
514
+ weight=payload.weight,
515
+ is_active=payload.is_active,
516
+ )
517
+ )
518
+ try:
519
+ await context.session.commit()
520
+ except IntegrityError as exc:
521
+ await context.session.rollback()
522
+ if _is_missing_proxy_endpoint_error(exc):
523
+ raise DashboardBadRequestError("Proxy endpoint not found", code="proxy_endpoint_not_found")
524
+ if _is_duplicate_proxy_pool_member_error(exc):
525
+ raise _duplicate_proxy_pool_member_error()
526
+ raise
527
+ # Pool membership is a route-resolver input: clear + durably bump before
528
+ # responding (same contract as the account-binding upsert).
529
+ await get_upstream_route_cache().invalidate()
530
+ endpoint_ids = (
531
+ (
532
+ await context.session.execute(
533
+ select(ProxyPoolMember.endpoint_id)
534
+ .where(ProxyPoolMember.pool_id == pool_id)
535
+ .order_by(ProxyPoolMember.sort_order.asc())
536
+ )
537
+ )
538
+ .scalars()
539
+ .all()
540
+ )
541
+ return UpstreamProxyPoolResponse(
542
+ id=pool.id,
543
+ name=pool.name,
544
+ is_active=pool.is_active,
545
+ endpoint_ids=list(endpoint_ids),
546
+ )
547
+
548
+
549
+ async def _validate_proxy_endpoint_ids(context: SettingsContext, endpoint_ids: list[str]) -> None:
550
+ if not endpoint_ids:
551
+ return
552
+ existing_ids = set(
553
+ (await context.session.execute(select(ProxyEndpoint.id).where(ProxyEndpoint.id.in_(endpoint_ids))))
554
+ .scalars()
555
+ .all()
556
+ )
557
+ missing_ids = [endpoint_id for endpoint_id in endpoint_ids if endpoint_id not in existing_ids]
558
+ if missing_ids:
559
+ raise DashboardBadRequestError(
560
+ f"Proxy endpoint not found: {', '.join(missing_ids)}",
561
+ code="proxy_endpoint_not_found",
562
+ )
563
+
564
+
565
+ async def _validate_proxy_pool_member_is_unique(
566
+ context: SettingsContext,
567
+ *,
568
+ pool_id: str,
569
+ endpoint_id: str,
570
+ ) -> None:
571
+ existing_id = (
572
+ await context.session.execute(
573
+ select(ProxyPoolMember.id)
574
+ .where(ProxyPoolMember.pool_id == pool_id, ProxyPoolMember.endpoint_id == endpoint_id)
575
+ .limit(1)
576
+ )
577
+ ).scalar_one_or_none()
578
+ if existing_id is not None:
579
+ raise _duplicate_proxy_pool_member_error()
580
+
581
+
582
+ async def _validate_proxy_pool_id(context: SettingsContext, pool_id: str | None) -> None:
583
+ if pool_id is None:
584
+ return
585
+ if await context.session.get(ProxyPool, pool_id) is None:
586
+ raise DashboardBadRequestError("Proxy pool not found", code="proxy_pool_not_found")
587
+
588
+
589
+ async def _get_account_or_error(context: SettingsContext, account_id: str) -> Account:
590
+ account = await context.session.get(Account, account_id)
591
+ # An account marked for background deletion is already deleted from the
592
+ # operator's point of view: binding mutations must report not-found, as
593
+ # the synchronous delete did once the row was removed.
594
+ if account is None or account.delete_requested_at is not None:
595
+ raise DashboardBadRequestError("Account not found", code="account_not_found")
596
+ return account
597
+
598
+
599
+ def _is_missing_proxy_endpoint_error(exc: Exception) -> bool:
600
+ message = str(exc).lower()
601
+ return "foreign key" in message or "fk constraint" in message or "violates foreign key constraint" in message
602
+
603
+
604
+ def _is_duplicate_proxy_pool_member_error(exc: Exception) -> bool:
605
+ message = str(exc).lower()
606
+ return (
607
+ "uq_proxy_pool_members_pool_endpoint" in message
608
+ or ("proxy_pool_members" in message and "unique" in message)
609
+ or ("proxy_pool_members.pool_id" in message and "proxy_pool_members.endpoint_id" in message)
610
+ )
611
+
612
+
613
+ def _duplicate_proxy_pool_member_error() -> DashboardBadRequestError:
614
+ return DashboardBadRequestError(
615
+ "Proxy endpoint is already a member of this pool",
616
+ code="proxy_pool_member_duplicate",
617
+ )
618
+
619
+
620
+ @router.put("/upstream-proxy/accounts/{account_id}/binding", response_model=AccountProxyBindingResponse)
621
+ async def put_account_proxy_binding(
622
+ account_id: str,
623
+ payload: AccountProxyBindingRequest,
624
+ request: Request,
625
+ _write_access=Depends(require_dashboard_write_access),
626
+ context: SettingsContext = Depends(get_settings_context),
627
+ ) -> AccountProxyBindingResponse:
628
+ account = await _get_account_or_error(context, account_id)
629
+ await _validate_proxy_pool_id(context, payload.pool_id)
630
+ row = (
631
+ (
632
+ await context.session.execute(
633
+ select(AccountProxyBinding).where(AccountProxyBinding.account_id == account_id).limit(1)
634
+ )
635
+ )
636
+ .scalars()
637
+ .one_or_none()
638
+ )
639
+ close_bridge_sessions = payload.is_active
640
+ if row is None:
641
+ row = AccountProxyBinding(account_id=account_id, pool_id=payload.pool_id, is_active=payload.is_active)
642
+ context.session.add(row)
643
+ else:
644
+ close_bridge_sessions = row.pool_id != payload.pool_id or row.is_active != payload.is_active
645
+ row.pool_id = payload.pool_id
646
+ row.is_active = payload.is_active
647
+ reactivated = False
648
+ if payload.is_active and _account_proxy_binding_should_reactivate(account):
649
+ account.status = AccountStatus.ACTIVE
650
+ account.deactivation_reason = None
651
+ account.reset_at = None
652
+ account.blocked_at = None
653
+ reactivated = True
654
+ await context.session.commit()
655
+ # Binding rows are a route-resolver input: clear this replica's route
656
+ # cache and durably bump ``upstream_route`` before responding so no
657
+ # request on the mutating replica resolves the pre-mutation binding.
658
+ await get_upstream_route_cache().invalidate()
659
+ if reactivated:
660
+ # Invalidate + bump only after the status commit so peers (and this
661
+ # replica's poller) never rebuild selection/routing inputs from the
662
+ # pre-commit row. The coalesced ``account_selection`` bump enqueued by
663
+ # ``invalidate()`` and the durable ``account_routing`` bump both fire
664
+ # post-commit, matching AccountService.reactivate_account.
665
+ clear_account_routing_unavailable(account_id)
666
+ get_account_selection_cache().invalidate()
667
+ await propagate_account_routing_change()
668
+ if close_bridge_sessions:
669
+ await get_proxy_service_for_app(request.app).close_http_bridge_sessions_for_account(account_id)
670
+ await context.session.refresh(row)
671
+ return AccountProxyBindingResponse(account_id=row.account_id, pool_id=row.pool_id, is_active=row.is_active)
672
+
673
+
674
+ def _proxy_endpoint_response(row: ProxyEndpoint) -> UpstreamProxyEndpointResponse:
675
+ return UpstreamProxyEndpointResponse(
676
+ id=row.id,
677
+ name=row.name,
678
+ scheme=row.scheme,
679
+ host=row.host,
680
+ port=row.port,
681
+ username=row.username,
682
+ is_active=row.is_active,
683
+ plaintext_credentials=sends_plaintext_credentials(
684
+ row.scheme, has_credentials=row.username is not None or row.password_encrypted is not None
685
+ ),
686
+ )
687
+
688
+
689
+ def _account_proxy_binding_should_reactivate(account: Account) -> bool:
690
+ reason = account.deactivation_reason or ""
691
+ return (
692
+ account.status == AccountStatus.PAUSED
693
+ and reason == IMPORT_PROXY_REQUIRED_PAUSE_REASON
694
+ or account.status == AccountStatus.DEACTIVATED
695
+ and (reason == "proxy_unreachable" or reason.startswith("proxy_unreachable:"))
696
+ )
697
+
698
+
699
+ async def _probe_upstream_proxy_endpoint(endpoint) -> int:
700
+ if endpoint.scheme.startswith("socks"):
701
+ connector = ProxyConnector(
702
+ host=endpoint.host,
703
+ port=endpoint.port,
704
+ proxy_type=ProxyType.SOCKS5,
705
+ username=endpoint.username,
706
+ password=endpoint.password,
707
+ rdns=endpoint.proxy_url.split(":", 1)[0] == "socks5h",
708
+ ssl=_shared_ssl_context(),
709
+ )
710
+ async with aiohttp.ClientSession(
711
+ connector=connector,
712
+ timeout=aiohttp.ClientTimeout(total=UPSTREAM_PROXY_TEST_TIMEOUT_SECONDS),
713
+ trust_env=False,
714
+ ) as client:
715
+ response = await client.get(UPSTREAM_PROXY_TEST_URL, allow_redirects=False)
716
+ return response.status
717
+ async with httpx.AsyncClient(
718
+ proxy=endpoint.proxy_url,
719
+ timeout=httpx.Timeout(UPSTREAM_PROXY_TEST_TIMEOUT_SECONDS),
720
+ follow_redirects=False,
721
+ ) as client:
722
+ response = await client.get(UPSTREAM_PROXY_TEST_URL)
723
+ return response.status_code
724
+
725
+
726
+ def _elapsed_ms(started: float) -> int:
727
+ return max(0, round((time.monotonic() - started) * 1000))
728
+
729
+
730
+ # C2-1 timeouts
731
+ # Dashboard-managed fields the timeout-invariant rules read: the C2-1 timeouts
732
+ # and request budgets plus the C2-2 account lease TTL (``account-lease-ttl-
733
+ # covers-*``), so one PUT-time check sees the merged effective values — a TTL
734
+ # below a dashboard budget and a budget above a dashboard TTL are both caught.
735
+ _TIMEOUT_INVARIANT_DASHBOARD_SETTINGS: tuple[str, ...] = (
736
+ *DASHBOARD_TIMEOUT_SETTINGS,
737
+ "proxy_account_lease_ttl_seconds", # C2-2 routing/overload
738
+ )
739
+
740
+
741
+ # M4 model catalogue: per-model context window overrides. One dashboard row per
742
+ # slug (``model_context_window_overrides``); the
743
+ # ``CODEX_LB_MODEL_CONTEXT_WINDOW_OVERRIDES`` entry is the per-slug fallback.
744
+ MODEL_CONTEXT_WINDOW_OVERRIDES_PATH = "/model-context-window-overrides"
745
+ _MODEL_SLUG_MAX_LENGTH = 256
746
+
747
+
748
+ def _validate_model_slug(slug: str) -> str:
749
+ # The raw segment is validated, never trimmed: silently storing " gpt-5.4 "
750
+ # as "gpt-5.4" would make the row the operator sees disagree with the slug
751
+ # they wrote, and the requirement rejects any slug containing whitespace.
752
+ if (
753
+ not slug
754
+ or len(slug) > _MODEL_SLUG_MAX_LENGTH
755
+ or any(character.isspace() or not character.isprintable() for character in slug)
756
+ ):
757
+ raise DashboardBadRequestError(
758
+ f"Model slug must be 1-{_MODEL_SLUG_MAX_LENGTH} printable characters without whitespace",
759
+ code="invalid_model_slug",
760
+ )
761
+ return slug
762
+
763
+
764
+ async def _model_context_window_overrides_response(context: SettingsContext) -> ModelContextWindowOverridesResponse:
765
+ dashboard = await ModelContextWindowOverridesRepository(context.session).by_slug()
766
+ resolved = resolve_context_window_overrides(dashboard, get_app_settings().model_context_window_overrides)
767
+ return ModelContextWindowOverridesResponse(
768
+ overrides=[
769
+ ModelContextWindowOverrideResponse(
770
+ slug=override.slug,
771
+ context_window=override.context_window,
772
+ source=override.source,
773
+ env_value=override.env_value,
774
+ )
775
+ for override in resolved.values()
776
+ ]
777
+ )
778
+
779
+
780
+ @router.get(MODEL_CONTEXT_WINDOW_OVERRIDES_PATH, response_model=ModelContextWindowOverridesResponse)
781
+ async def get_model_context_window_overrides(
782
+ context: SettingsContext = Depends(get_settings_context),
783
+ ) -> ModelContextWindowOverridesResponse:
784
+ return await _model_context_window_overrides_response(context)
785
+
786
+
787
+ @router.put(MODEL_CONTEXT_WINDOW_OVERRIDES_PATH + "/{slug:path}", response_model=ModelContextWindowOverridesResponse)
788
+ async def put_model_context_window_override(
789
+ request: Request,
790
+ slug: str,
791
+ payload: ModelContextWindowOverrideUpsertRequest,
792
+ _write_access=Depends(require_dashboard_write_access),
793
+ context: SettingsContext = Depends(get_settings_context),
794
+ ) -> ModelContextWindowOverridesResponse:
795
+ normalized = _validate_model_slug(slug)
796
+ await ModelContextWindowOverridesRepository(context.session).upsert(normalized, payload.context_window)
797
+ # The catalog reads a cached snapshot of the rows: clear + durably bump
798
+ # before responding so every replica reports the new window.
799
+ await get_model_context_window_overrides_cache().invalidate()
800
+ # Audited like every other dashboard settings write: this one changes what
801
+ # the model catalog advertises to every client.
802
+ AuditService.log_async(
803
+ "settings_changed",
804
+ actor_ip=request.client.host if request.client else None,
805
+ details={"changed_fields": ["model_context_window_overrides"], "slug": normalized},
806
+ )
807
+ return await _model_context_window_overrides_response(context)
808
+
809
+
810
+ @router.delete(MODEL_CONTEXT_WINDOW_OVERRIDES_PATH + "/{slug:path}", response_model=ModelContextWindowOverridesResponse)
811
+ async def delete_model_context_window_override(
812
+ request: Request,
813
+ slug: str,
814
+ _write_access=Depends(require_dashboard_write_access),
815
+ context: SettingsContext = Depends(get_settings_context),
816
+ ) -> ModelContextWindowOverridesResponse:
817
+ normalized = _validate_model_slug(slug)
818
+ if not await ModelContextWindowOverridesRepository(context.session).delete(normalized):
819
+ raise DashboardNotFoundError(
820
+ "Model context window override not found", code="model_context_window_override_not_found"
821
+ )
822
+ await get_model_context_window_overrides_cache().invalidate()
823
+ AuditService.log_async(
824
+ "settings_changed",
825
+ actor_ip=request.client.host if request.client else None,
826
+ details={"changed_fields": ["model_context_window_overrides"], "slug": normalized},
827
+ )
828
+ return await _model_context_window_overrides_response(context)
829
+
830
+
831
+ # end M4 model catalogue
832
+
833
+
834
+ def _proposed_timeout_settings(payload: DashboardSettingsUpdateRequest, current, startup_settings) -> dict[str, float]:
835
+ """Effective timeout values after ``payload`` is applied: value = store, null = inherit, absent = current."""
836
+ proposed: dict[str, float] = {}
837
+ for name in _TIMEOUT_INVARIANT_DASHBOARD_SETTINGS:
838
+ if name in payload.model_fields_set:
839
+ value = getattr(payload, name)
840
+ if value is None: # inherit: the environment value, or the process default for a partial fake
841
+ value = getattr(startup_settings, name, None)
842
+ if value is None:
843
+ value = getattr(settings_module.get_settings(), name)
844
+ proposed[name] = float(value)
845
+ else:
846
+ proposed[name] = float(getattr(current, name))
847
+ return proposed
848
+
849
+
850
+ class _EffectiveTimeoutView:
851
+ """``TimeoutSettings`` view: effective timeout values over the startup settings.
852
+
853
+ Fields a startup-settings fake does not carry are read from the process
854
+ ``Settings`` so constant-only rules keep evaluating.
855
+ """
856
+
857
+ def __init__(self, base: object, overrides: Mapping[str, float]) -> None:
858
+ self._base = base
859
+ self._overrides = overrides
860
+
861
+ def __getattr__(self, name: str) -> object:
862
+ if name in self._overrides:
863
+ return self._overrides[name]
864
+ if hasattr(self._base, name):
865
+ return getattr(self._base, name)
866
+ return getattr(settings_module.get_settings(), name)
867
+
868
+
869
+ def _validate_timeout_invariants(payload: DashboardSettingsUpdateRequest, current, startup_settings) -> None:
870
+ """Reject a PUT that introduces a timeout-invariant violation against the effective values.
871
+
872
+ The same rules as startup (``app.core.timeout_invariants``) are evaluated on
873
+ the effective settings — dashboard value, else environment, else default —
874
+ before and after the change; only violations the change introduces are
875
+ rejected, so a deployment whose environment already violates a rule can
876
+ still edit unrelated fields. The C2-2 account lease TTL takes part in the
877
+ same evaluation, so lowering a budget below a dashboard TTL, or storing a
878
+ TTL below an effective budget, is rejected on the merged values.
879
+ """
880
+ if not set(_TIMEOUT_INVARIANT_DASHBOARD_SETTINGS) & payload.model_fields_set:
881
+ return
882
+ before = _EffectiveTimeoutView(
883
+ startup_settings, {name: float(getattr(current, name)) for name in _TIMEOUT_INVARIANT_DASHBOARD_SETTINGS}
884
+ )
885
+ after = _EffectiveTimeoutView(startup_settings, _proposed_timeout_settings(payload, current, startup_settings))
886
+ before_ids = {violation.rule.id for violation in find_timeout_invariant_violations(cast(Any, before))}
887
+ introduced = [
888
+ violation
889
+ for violation in find_timeout_invariant_violations(cast(Any, after))
890
+ if violation.rule.id not in before_ids
891
+ ]
892
+ if introduced:
893
+ raise DashboardBadRequestError(
894
+ "; ".join(violation.format() for violation in introduced),
895
+ code="timeout_invariant_violation",
896
+ )
897
+
898
+
899
+ # R2 spool retention
900
+ class _ProposedSpoolRetentionInputs:
901
+ """Effective floor inputs after ``payload`` is applied, over ``current``.
902
+
903
+ The three inputs behave differently in the update request: the two reuse
904
+ windows are non-nullable dashboard columns with no environment layer, so a
905
+ ``None`` there only ever means "unchanged"; the bridge request budget is
906
+ tri-state, so an explicit null returns it to the environment value.
907
+ """
908
+
909
+ __slots__ = ("_values",)
910
+
911
+ def __init__(self, payload: DashboardSettingsUpdateRequest, current, startup_settings) -> None:
912
+ values: dict[str, float] = {}
913
+ for name in SPOOL_RETENTION_FLOOR_INPUTS:
914
+ proposed = getattr(payload, name, None)
915
+ inheritable = name in settings_module.Settings.model_fields
916
+ if proposed is None and inheritable and name in payload.model_fields_set:
917
+ # Explicit null on a tri-state field: inherit the environment
918
+ # value (the process default for a partial startup fake).
919
+ proposed = getattr(startup_settings, name, None)
920
+ if proposed is None:
921
+ proposed = getattr(settings_module.get_settings(), name)
922
+ values[name] = float(proposed if proposed is not None else getattr(current, name))
923
+ self._values = values
924
+
925
+ def __getattr__(self, name: str) -> object:
926
+ try:
927
+ return self._values[name]
928
+ except KeyError as exc: # keep ``getattr(obj, name, default)`` working
929
+ raise AttributeError(name) from exc
930
+
931
+
932
+ def _proposed_spool_retention_seconds(payload: DashboardSettingsUpdateRequest, current, startup_settings) -> float:
933
+ """Effective spool retention after ``payload``: value = store, null = inherit, absent = current."""
934
+ if OPERATION_SPOOL_RETENTION_SETTING not in payload.model_fields_set:
935
+ return float(getattr(current, OPERATION_SPOOL_RETENTION_SETTING))
936
+ proposed = getattr(payload, OPERATION_SPOOL_RETENTION_SETTING)
937
+ if proposed is not None:
938
+ return float(proposed)
939
+ inherited = current.provenance[OPERATION_SPOOL_RETENTION_SETTING]
940
+ return float(resolve_inheritable(None, inherited.env_value, inherited.default).value)
941
+
942
+
943
+ def _validate_spool_retention_floor(payload: DashboardSettingsUpdateRequest, current, startup_settings) -> None:
944
+ """Reject a PUT that would leave the operation spool shorter than its replay floor.
945
+
946
+ The spool holds the raw request payload and the response events that
947
+ durable bridge recovery replays, so it must outlive every window in which a
948
+ spooled operation may still be read. Both the retention window and the floor
949
+ are evaluated on the effective values (dashboard column, else environment,
950
+ else code default) before and after the change.
951
+
952
+ A configuration can already be below the floor without ever passing through
953
+ here -- the environment alias alone decides it while the column is NULL --
954
+ and that state must not make the dashboard read-only. The rule is therefore
955
+ "no worse", not "bypass": while below the floor an update is accepted only
956
+ when it leaves the retention no shorter and the floor no higher than it
957
+ found them, so unrelated edits go through but deepening the violation does
958
+ not. Startup warns about the state (``_report_dashboard_timeout_overrides``)
959
+ so the first refusal is never a surprise.
960
+ """
961
+ if not {OPERATION_SPOOL_RETENTION_SETTING, *SPOOL_RETENTION_FLOOR_INPUTS} & payload.model_fields_set:
962
+ return
963
+ before_retention = float(getattr(current, OPERATION_SPOOL_RETENTION_SETTING))
964
+ before_floor = operation_spool_retention_floor_seconds(current, startup_settings=startup_settings)
965
+ after_inputs = _ProposedSpoolRetentionInputs(payload, current, startup_settings)
966
+ after_retention = _proposed_spool_retention_seconds(payload, current, startup_settings)
967
+ term, after_floor = binding_spool_retention_floor_term(after_inputs, startup_settings=startup_settings)
968
+ if after_retention >= after_floor:
969
+ return
970
+ if after_retention >= before_retention and after_floor <= before_floor:
971
+ # Neither side got worse. Only reachable from an already-violating
972
+ # state: a healthy one has before_retention >= before_floor >=
973
+ # after_floor > after_retention >= before_retention, a contradiction.
974
+ return
975
+ if before_retention < before_floor:
976
+ raise DashboardBadRequestError(
977
+ f"{OPERATION_SPOOL_RETENTION_SETTING} is already below its replay floor "
978
+ f"({before_retention:g}s < {before_floor:g}s) and this change would deepen it "
979
+ f"({before_retention:g}s -> {after_retention:g}s against a floor of "
980
+ f"{before_floor:g}s -> {after_floor:g}s, bound by {term}); raise the retention to at least "
981
+ f"{after_floor:g}s, or leave both no worse than they are",
982
+ code="spool_retention_below_floor",
983
+ )
984
+ raise DashboardBadRequestError(
985
+ f"{OPERATION_SPOOL_RETENTION_SETTING} must be at least {after_floor:g}s: the operation spool is replayed "
986
+ f"for as long as {term} ({after_floor:g}s), so a shorter window deletes transcripts a recovery still needs",
987
+ code="spool_retention_below_floor",
988
+ )
989
+
990
+
991
+ # end R2 spool retention
992
+
993
+
994
+ def _proposed_reset_credit_polling_enabled(payload: DashboardSettingsUpdateRequest, current) -> bool:
995
+ """M2 background jobs: the reset-credit polling toggle as it will be after this update.
996
+
997
+ A value in the payload wins; an explicit null returns to the inherited
998
+ environment / default value; an omitted field keeps the current effective
999
+ value (dashboard column, else the deprecated env alias, else the default).
1000
+ """
1001
+ name = "rate_limit_reset_credits_refresh_enabled"
1002
+ if name not in payload.model_fields_set:
1003
+ return bool(getattr(current, name))
1004
+ proposed = getattr(payload, name)
1005
+ if proposed is not None:
1006
+ return bool(proposed)
1007
+ inherited = current.provenance[name]
1008
+ return bool(resolve_inheritable(None, inherited.env_value, inherited.default).value)
1009
+
1010
+
1011
+ def _proposed_auto_redeem_enabled(payload: DashboardSettingsUpdateRequest, current) -> bool:
1012
+ """M2 background jobs: the auto-redeem opt-in as it will be after this update."""
1013
+ if payload.auto_redeem_reset_credits_before_expiry is None:
1014
+ return bool(current.auto_redeem_reset_credits_before_expiry)
1015
+ return bool(payload.auto_redeem_reset_credits_before_expiry)
1016
+
1017
+
1018
+ def _timeout_field(payload: DashboardSettingsUpdateRequest, name: str) -> tuple[float | None, bool]:
1019
+ """(value to store, clear flag) for one tri-state timeout field of ``payload``."""
1020
+ if name not in payload.model_fields_set:
1021
+ return None, False
1022
+ value = getattr(payload, name)
1023
+ return value, value is None
1024
+
1025
+
1026
+ # end C2-1 timeouts
1027
+
1028
+
1029
+ @router.put("", response_model=DashboardSettingsResponse)
1030
+ async def update_settings(
1031
+ request: Request,
1032
+ payload: DashboardSettingsUpdateRequest = Body(...),
1033
+ principal: DashboardPrincipal = Depends(require_dashboard_write_access),
1034
+ context: SettingsContext = Depends(get_settings_context),
1035
+ ) -> DashboardSettingsResponse:
1036
+ current = await context.service.get_settings(actor_user_id=principal.user_id)
1037
+ # The dashboard client submits the whole form on every save, so a security
1038
+ # field merely being present must not require security:write; only a value
1039
+ # that differs from what is stored does.
1040
+ security_changes = {
1041
+ name
1042
+ for name in payload.model_fields_set & SECURITY_SETTINGS_FIELDS
1043
+ if getattr(payload, name) is not None and getattr(payload, name) != getattr(current, name)
1044
+ }
1045
+ if security_changes:
1046
+ ensure_dashboard_permission(principal, Permission.SECURITY_WRITE)
1047
+ await ensure_step_up(request, principal, Permission.SECURITY_WRITE)
1048
+ if payload.expected_version is not None and payload.expected_version != current.version:
1049
+ raise DashboardSettingsConflictError(
1050
+ "Settings were modified since this form was loaded; reload and retry",
1051
+ )
1052
+ if (
1053
+ "upstream_proxy_default_pool_id" in payload.model_fields_set
1054
+ and payload.upstream_proxy_default_pool_id is not None
1055
+ ):
1056
+ await _validate_proxy_pool_id(context, payload.upstream_proxy_default_pool_id)
1057
+ # The reset-credit refresh loop is the sole driver of automatic redemption,
1058
+ # so "auto-redeem on, polling off" is a setting that can never run. The gate
1059
+ # gets the effective (dashboard-aware) values this request would leave
1060
+ # behind and is symmetric: it refuses both the request that turns the
1061
+ # opt-in on and the one that turns polling off. A payload that only
1062
+ # re-saves an already inconsistent pair is still accepted, so unrelated
1063
+ # settings edits are never blocked by pre-existing state.
1064
+ proposed_polling_enabled = _proposed_reset_credit_polling_enabled(payload, current)
1065
+ proposed_auto_redeem_enabled = _proposed_auto_redeem_enabled(payload, current)
1066
+ if proposed_auto_redeem_enabled and not proposed_polling_enabled:
1067
+ enables_auto_redeem = not current.auto_redeem_reset_credits_before_expiry
1068
+ disables_polling = (
1069
+ "rate_limit_reset_credits_refresh_enabled" in payload.model_fields_set
1070
+ and current.rate_limit_reset_credits_refresh_enabled
1071
+ )
1072
+ if enables_auto_redeem or disables_polling:
1073
+ raise DashboardBadRequestError(
1074
+ "autoRedeemResetCreditsBeforeExpiry requires reset-credit polling; "
1075
+ "keep rateLimitResetCreditsRefreshEnabled on (Settings -> Advanced -> Background jobs), "
1076
+ "or turn the opt-in off in the same request",
1077
+ code="reset_credit_polling_disabled",
1078
+ )
1079
+ try:
1080
+ legacy_threshold_provided = payload.sticky_reallocation_budget_threshold_pct is not None
1081
+ primary_threshold_provided = payload.sticky_reallocation_primary_budget_threshold_pct is not None
1082
+ if legacy_threshold_provided and primary_threshold_provided:
1083
+ assert payload.sticky_reallocation_budget_threshold_pct is not None
1084
+ assert payload.sticky_reallocation_primary_budget_threshold_pct is not None
1085
+ if (
1086
+ payload.sticky_reallocation_budget_threshold_pct
1087
+ != payload.sticky_reallocation_primary_budget_threshold_pct
1088
+ and (
1089
+ payload.sticky_reallocation_budget_threshold_pct != current.sticky_reallocation_budget_threshold_pct
1090
+ or payload.sticky_reallocation_primary_budget_threshold_pct
1091
+ != current.sticky_reallocation_primary_budget_threshold_pct
1092
+ )
1093
+ ):
1094
+ raise DashboardBadRequestError(
1095
+ "stickyReallocationBudgetThresholdPct and "
1096
+ "stickyReallocationPrimaryBudgetThresholdPct must match when both are provided",
1097
+ code="conflicting_sticky_reallocation_thresholds",
1098
+ )
1099
+
1100
+ resolved_primary_threshold = (
1101
+ payload.sticky_reallocation_primary_budget_threshold_pct
1102
+ if payload.sticky_reallocation_primary_budget_threshold_pct is not None
1103
+ else (
1104
+ payload.sticky_reallocation_budget_threshold_pct
1105
+ if payload.sticky_reallocation_budget_threshold_pct is not None
1106
+ else current.sticky_reallocation_primary_budget_threshold_pct
1107
+ )
1108
+ )
1109
+ resolved_legacy_threshold = (
1110
+ payload.sticky_reallocation_budget_threshold_pct
1111
+ if payload.sticky_reallocation_budget_threshold_pct is not None
1112
+ else resolved_primary_threshold
1113
+ )
1114
+ single_account_id = (
1115
+ payload.single_account_id if "single_account_id" in payload.model_fields_set else current.single_account_id
1116
+ )
1117
+ startup_settings = get_app_settings()
1118
+ stream_limit = (
1119
+ payload.proxy_account_stream_limit
1120
+ if payload.proxy_account_stream_limit is not None
1121
+ else startup_settings.proxy_account_stream_limit
1122
+ if "proxy_account_stream_limit" in payload.model_fields_set
1123
+ else current.proxy_account_stream_limit
1124
+ )
1125
+ stream_recovery_reserve = (
1126
+ payload.proxy_account_stream_recovery_reserve
1127
+ if payload.proxy_account_stream_recovery_reserve is not None
1128
+ else startup_settings.proxy_account_stream_recovery_reserve
1129
+ if "proxy_account_stream_recovery_reserve" in payload.model_fields_set
1130
+ else current.proxy_account_stream_recovery_reserve
1131
+ )
1132
+ cap_fields_changed = bool(
1133
+ {
1134
+ "proxy_account_stream_limit",
1135
+ "proxy_account_stream_recovery_reserve",
1136
+ }
1137
+ & payload.model_fields_set
1138
+ )
1139
+ if cap_fields_changed and stream_limit > 0 and stream_recovery_reserve > stream_limit:
1140
+ raise DashboardBadRequestError(
1141
+ "proxyAccountStreamRecoveryReserve must not exceed proxyAccountStreamLimit",
1142
+ code="invalid_proxy_account_stream_recovery_reserve",
1143
+ )
1144
+ _validate_timeout_invariants(payload, current, startup_settings) # C2-1 timeouts + C2-2 lease TTL
1145
+ _validate_spool_retention_floor(payload, current, startup_settings) # R2 spool retention
1146
+ timeout_fields = {name: _timeout_field(payload, name) for name in DASHBOARD_TIMEOUT_SETTINGS}
1147
+ updated = await context.service.update_settings(
1148
+ DashboardSettingsUpdateData(
1149
+ sticky_threads_enabled=(
1150
+ payload.sticky_threads_enabled
1151
+ if payload.sticky_threads_enabled is not None
1152
+ else current.sticky_threads_enabled
1153
+ ),
1154
+ upstream_stream_transport=payload.upstream_stream_transport or current.upstream_stream_transport,
1155
+ prohibit_fast_mode=(
1156
+ payload.prohibit_fast_mode if payload.prohibit_fast_mode is not None else current.prohibit_fast_mode
1157
+ ),
1158
+ http_downstream_transport_policy=(
1159
+ payload.http_downstream_transport_policy or current.http_downstream_transport_policy
1160
+ ),
1161
+ thread_cache_identity_mode=_dashboard_value(payload, "thread_cache_identity_mode"),
1162
+ clear_thread_cache_identity_mode=_clears_dashboard_value(payload, "thread_cache_identity_mode"),
1163
+ proxy_account_response_create_limit=(
1164
+ payload.proxy_account_response_create_limit
1165
+ if "proxy_account_response_create_limit" in payload.model_fields_set
1166
+ else None
1167
+ ),
1168
+ clear_proxy_account_response_create_limit=(
1169
+ "proxy_account_response_create_limit" in payload.model_fields_set
1170
+ and payload.proxy_account_response_create_limit is None
1171
+ ),
1172
+ proxy_account_stream_limit=(
1173
+ payload.proxy_account_stream_limit
1174
+ if "proxy_account_stream_limit" in payload.model_fields_set
1175
+ else None
1176
+ ),
1177
+ clear_proxy_account_stream_limit=(
1178
+ "proxy_account_stream_limit" in payload.model_fields_set
1179
+ and payload.proxy_account_stream_limit is None
1180
+ ),
1181
+ proxy_account_stream_recovery_reserve=(
1182
+ payload.proxy_account_stream_recovery_reserve
1183
+ if "proxy_account_stream_recovery_reserve" in payload.model_fields_set
1184
+ else None
1185
+ ),
1186
+ clear_proxy_account_stream_recovery_reserve=(
1187
+ "proxy_account_stream_recovery_reserve" in payload.model_fields_set
1188
+ and payload.proxy_account_stream_recovery_reserve is None
1189
+ ),
1190
+ proxy_api_key_fair_share_congestion_threshold_pct=(
1191
+ payload.proxy_api_key_fair_share_congestion_threshold_pct
1192
+ if "proxy_api_key_fair_share_congestion_threshold_pct" in payload.model_fields_set
1193
+ else None
1194
+ ),
1195
+ clear_proxy_api_key_fair_share_congestion_threshold_pct=(
1196
+ "proxy_api_key_fair_share_congestion_threshold_pct" in payload.model_fields_set
1197
+ and payload.proxy_api_key_fair_share_congestion_threshold_pct is None
1198
+ ),
1199
+ # C2-2 routing/overload
1200
+ proxy_overload_isolation_seconds=_dashboard_value(payload, "proxy_overload_isolation_seconds"),
1201
+ clear_proxy_overload_isolation_seconds=_clears_dashboard_value(
1202
+ payload, "proxy_overload_isolation_seconds"
1203
+ ),
1204
+ proxy_account_error_rate_weighting_enabled=_dashboard_value(
1205
+ payload, "proxy_account_error_rate_weighting_enabled"
1206
+ ),
1207
+ clear_proxy_account_error_rate_weighting_enabled=_clears_dashboard_value(
1208
+ payload, "proxy_account_error_rate_weighting_enabled"
1209
+ ),
1210
+ proxy_account_inflight_penalty_pct=_dashboard_value(payload, "proxy_account_inflight_penalty_pct"),
1211
+ clear_proxy_account_inflight_penalty_pct=_clears_dashboard_value(
1212
+ payload, "proxy_account_inflight_penalty_pct"
1213
+ ),
1214
+ proxy_account_lease_token_weight=_dashboard_value(payload, "proxy_account_lease_token_weight"),
1215
+ clear_proxy_account_lease_token_weight=_clears_dashboard_value(
1216
+ payload, "proxy_account_lease_token_weight"
1217
+ ),
1218
+ proxy_account_lease_ttl_seconds=_dashboard_value(payload, "proxy_account_lease_ttl_seconds"),
1219
+ clear_proxy_account_lease_ttl_seconds=_clears_dashboard_value(
1220
+ payload, "proxy_account_lease_ttl_seconds"
1221
+ ),
1222
+ # end C2-2 routing/overload
1223
+ upstream_proxy_routing_enabled=(
1224
+ payload.upstream_proxy_routing_enabled
1225
+ if payload.upstream_proxy_routing_enabled is not None
1226
+ else current.upstream_proxy_routing_enabled
1227
+ ),
1228
+ upstream_proxy_default_pool_id=(
1229
+ payload.upstream_proxy_default_pool_id
1230
+ if "upstream_proxy_default_pool_id" in payload.model_fields_set
1231
+ else current.upstream_proxy_default_pool_id
1232
+ ),
1233
+ prefer_earlier_reset_accounts=(
1234
+ payload.prefer_earlier_reset_accounts
1235
+ if payload.prefer_earlier_reset_accounts is not None
1236
+ else current.prefer_earlier_reset_accounts
1237
+ ),
1238
+ prefer_earlier_reset_window=payload.prefer_earlier_reset_window or current.prefer_earlier_reset_window,
1239
+ show_reset_credit_badges=(
1240
+ payload.show_reset_credit_badges
1241
+ if payload.show_reset_credit_badges is not None
1242
+ else current.show_reset_credit_badges
1243
+ ),
1244
+ auto_redeem_reset_credits_before_expiry=(
1245
+ payload.auto_redeem_reset_credits_before_expiry
1246
+ if payload.auto_redeem_reset_credits_before_expiry is not None
1247
+ else current.auto_redeem_reset_credits_before_expiry
1248
+ ),
1249
+ show_reset_credit_expiry_badge=(
1250
+ payload.show_reset_credit_expiry_badge
1251
+ if payload.show_reset_credit_expiry_badge is not None
1252
+ else current.show_reset_credit_expiry_badge
1253
+ ),
1254
+ routing_strategy=payload.routing_strategy or current.routing_strategy,
1255
+ relative_availability_power=(
1256
+ payload.relative_availability_power
1257
+ if payload.relative_availability_power is not None
1258
+ else current.relative_availability_power
1259
+ ),
1260
+ relative_availability_top_k=(
1261
+ payload.relative_availability_top_k
1262
+ if payload.relative_availability_top_k is not None
1263
+ else current.relative_availability_top_k
1264
+ ),
1265
+ single_account_id=single_account_id,
1266
+ openai_cache_affinity_max_age_seconds=(
1267
+ payload.openai_cache_affinity_max_age_seconds
1268
+ if payload.openai_cache_affinity_max_age_seconds is not None
1269
+ else current.openai_cache_affinity_max_age_seconds
1270
+ ),
1271
+ dashboard_session_ttl_seconds=(
1272
+ payload.dashboard_session_ttl_seconds
1273
+ if payload.dashboard_session_ttl_seconds is not None
1274
+ else current.dashboard_session_ttl_seconds
1275
+ ),
1276
+ http_responses_session_bridge_prompt_cache_idle_ttl_seconds=(
1277
+ payload.http_responses_session_bridge_prompt_cache_idle_ttl_seconds
1278
+ if payload.http_responses_session_bridge_prompt_cache_idle_ttl_seconds is not None
1279
+ else current.http_responses_session_bridge_prompt_cache_idle_ttl_seconds
1280
+ ),
1281
+ http_responses_session_bridge_gateway_safe_mode=(
1282
+ payload.http_responses_session_bridge_gateway_safe_mode
1283
+ if payload.http_responses_session_bridge_gateway_safe_mode is not None
1284
+ else current.http_responses_session_bridge_gateway_safe_mode
1285
+ ),
1286
+ # M3 codex prewarm: tri-state via model_fields_set.
1287
+ http_responses_session_bridge_codex_prewarm_enabled=_dashboard_value(
1288
+ payload, "http_responses_session_bridge_codex_prewarm_enabled"
1289
+ ),
1290
+ clear_http_responses_session_bridge_codex_prewarm_enabled=_clears_dashboard_value(
1291
+ payload, "http_responses_session_bridge_codex_prewarm_enabled"
1292
+ ),
1293
+ # end M3 codex prewarm
1294
+ sticky_reallocation_budget_threshold_pct=resolved_legacy_threshold,
1295
+ sticky_reallocation_primary_budget_threshold_pct=resolved_primary_threshold,
1296
+ sticky_reallocation_secondary_budget_threshold_pct=(
1297
+ payload.sticky_reallocation_secondary_budget_threshold_pct
1298
+ if payload.sticky_reallocation_secondary_budget_threshold_pct is not None
1299
+ else current.sticky_reallocation_secondary_budget_threshold_pct
1300
+ ),
1301
+ additional_quota_routing_policies=(
1302
+ payload.additional_quota_routing_policies
1303
+ if payload.additional_quota_routing_policies is not None
1304
+ else current.additional_quota_routing_policies
1305
+ ),
1306
+ warmup_model=(payload.warmup_model if payload.warmup_model is not None else current.warmup_model),
1307
+ import_without_overwrite=(
1308
+ payload.import_without_overwrite
1309
+ if payload.import_without_overwrite is not None
1310
+ else current.import_without_overwrite
1311
+ ),
1312
+ totp_required_on_login=(
1313
+ payload.totp_required_on_login
1314
+ if payload.totp_required_on_login is not None
1315
+ else current.totp_required_on_login
1316
+ ),
1317
+ totp_required_for_admin_role=(
1318
+ payload.totp_required_for_admin_role
1319
+ if payload.totp_required_for_admin_role is not None
1320
+ else current.totp_required_for_admin_role
1321
+ ),
1322
+ local_login_policy=(
1323
+ payload.local_login_policy if payload.local_login_policy is not None else current.local_login_policy
1324
+ ),
1325
+ api_key_auth_enabled=(
1326
+ payload.api_key_auth_enabled
1327
+ if payload.api_key_auth_enabled is not None
1328
+ else current.api_key_auth_enabled
1329
+ ),
1330
+ hide_upstream_quota_from_api_keys=(
1331
+ payload.hide_upstream_quota_from_api_keys
1332
+ if payload.hide_upstream_quota_from_api_keys is not None
1333
+ else current.hide_upstream_quota_from_api_keys
1334
+ ),
1335
+ limit_warmup_enabled=(
1336
+ payload.limit_warmup_enabled
1337
+ if payload.limit_warmup_enabled is not None
1338
+ else current.limit_warmup_enabled
1339
+ ),
1340
+ limit_warmup_windows=payload.limit_warmup_windows or current.limit_warmup_windows,
1341
+ limit_warmup_model=payload.limit_warmup_model or current.limit_warmup_model,
1342
+ limit_warmup_prompt=payload.limit_warmup_prompt or current.limit_warmup_prompt,
1343
+ limit_warmup_cooldown_seconds=(
1344
+ payload.limit_warmup_cooldown_seconds
1345
+ if payload.limit_warmup_cooldown_seconds is not None
1346
+ else current.limit_warmup_cooldown_seconds
1347
+ ),
1348
+ limit_warmup_exhausted_threshold_percent=(
1349
+ payload.limit_warmup_exhausted_threshold_percent
1350
+ if payload.limit_warmup_exhausted_threshold_percent is not None
1351
+ else current.limit_warmup_exhausted_threshold_percent
1352
+ ),
1353
+ limit_warmup_idle_threshold_percent=(
1354
+ payload.limit_warmup_idle_threshold_percent
1355
+ if payload.limit_warmup_idle_threshold_percent is not None
1356
+ else current.limit_warmup_idle_threshold_percent
1357
+ ),
1358
+ limit_warmup_min_available_percent=(
1359
+ payload.limit_warmup_min_available_percent
1360
+ if payload.limit_warmup_min_available_percent is not None
1361
+ else current.limit_warmup_min_available_percent
1362
+ ),
1363
+ weekly_pace_working_days=(
1364
+ payload.weekly_pace_working_days
1365
+ if payload.weekly_pace_working_days is not None
1366
+ else current.weekly_pace_working_days
1367
+ ),
1368
+ weekly_pace_smoothing_minutes=(
1369
+ payload.weekly_pace_smoothing_minutes
1370
+ if payload.weekly_pace_smoothing_minutes is not None
1371
+ else current.weekly_pace_smoothing_minutes
1372
+ ),
1373
+ guest_access_enabled=(
1374
+ payload.guest_access_enabled
1375
+ if payload.guest_access_enabled is not None
1376
+ else current.guest_access_enabled
1377
+ ),
1378
+ limit_warmup_staggered_idle_enabled=(
1379
+ payload.limit_warmup_staggered_idle_enabled
1380
+ if payload.limit_warmup_staggered_idle_enabled is not None
1381
+ else current.limit_warmup_staggered_idle_enabled
1382
+ ),
1383
+ request_log_retention_override_days=(
1384
+ payload.request_log_retention_override_days
1385
+ if "request_log_retention_override_days" in payload.model_fields_set
1386
+ else None
1387
+ ),
1388
+ usage_history_retention_override_days=(
1389
+ payload.usage_history_retention_override_days
1390
+ if "usage_history_retention_override_days" in payload.model_fields_set
1391
+ else None
1392
+ ),
1393
+ clear_request_log_retention_override=(
1394
+ "request_log_retention_override_days" in payload.model_fields_set
1395
+ and payload.request_log_retention_override_days is None
1396
+ ),
1397
+ clear_usage_history_retention_override=(
1398
+ "usage_history_retention_override_days" in payload.model_fields_set
1399
+ and payload.usage_history_retention_override_days is None
1400
+ ),
1401
+ # C2-3 resilience toggles: tri-state via model_fields_set.
1402
+ soft_drain_enabled=(
1403
+ payload.soft_drain_enabled if "soft_drain_enabled" in payload.model_fields_set else None
1404
+ ),
1405
+ clear_soft_drain_enabled=(
1406
+ "soft_drain_enabled" in payload.model_fields_set and payload.soft_drain_enabled is None
1407
+ ),
1408
+ deterministic_failover_enabled=(
1409
+ payload.deterministic_failover_enabled
1410
+ if "deterministic_failover_enabled" in payload.model_fields_set
1411
+ else None
1412
+ ),
1413
+ clear_deterministic_failover_enabled=(
1414
+ "deterministic_failover_enabled" in payload.model_fields_set
1415
+ and payload.deterministic_failover_enabled is None
1416
+ ),
1417
+ circuit_breaker_enabled=(
1418
+ payload.circuit_breaker_enabled if "circuit_breaker_enabled" in payload.model_fields_set else None
1419
+ ),
1420
+ clear_circuit_breaker_enabled=(
1421
+ "circuit_breaker_enabled" in payload.model_fields_set and payload.circuit_breaker_enabled is None
1422
+ ),
1423
+ # M2 background jobs: tri-state via model_fields_set.
1424
+ auth_guardian_enabled=_dashboard_value(payload, "auth_guardian_enabled"),
1425
+ clear_auth_guardian_enabled=_clears_dashboard_value(payload, "auth_guardian_enabled"),
1426
+ automations_scheduler_enabled=_dashboard_value(payload, "automations_scheduler_enabled"),
1427
+ clear_automations_scheduler_enabled=_clears_dashboard_value(payload, "automations_scheduler_enabled"),
1428
+ rate_limit_reset_credits_refresh_enabled=_dashboard_value(
1429
+ payload, "rate_limit_reset_credits_refresh_enabled"
1430
+ ),
1431
+ clear_rate_limit_reset_credits_refresh_enabled=_clears_dashboard_value(
1432
+ payload, "rate_limit_reset_credits_refresh_enabled"
1433
+ ),
1434
+ # end M2 background jobs
1435
+ # M5 conversation archive: tri-state via model_fields_set.
1436
+ conversation_archive_enabled=(
1437
+ payload.conversation_archive_enabled
1438
+ if CONVERSATION_ARCHIVE_SETTING in payload.model_fields_set
1439
+ else None
1440
+ ),
1441
+ clear_conversation_archive_enabled=(
1442
+ CONVERSATION_ARCHIVE_SETTING in payload.model_fields_set
1443
+ and payload.conversation_archive_enabled is None
1444
+ ),
1445
+ # end M5 conversation archive
1446
+ # R2 spool retention: tri-state via model_fields_set.
1447
+ http_responses_session_bridge_operation_spool_retention_seconds=_dashboard_value(
1448
+ payload, OPERATION_SPOOL_RETENTION_SETTING
1449
+ ),
1450
+ clear_http_responses_session_bridge_operation_spool_retention_seconds=_clears_dashboard_value(
1451
+ payload, OPERATION_SPOOL_RETENTION_SETTING
1452
+ ),
1453
+ # end R2 spool retention
1454
+ # C2-1 timeouts
1455
+ upstream_connect_timeout_seconds=timeout_fields["upstream_connect_timeout_seconds"][0],
1456
+ clear_upstream_connect_timeout_seconds=timeout_fields["upstream_connect_timeout_seconds"][1],
1457
+ proxy_request_budget_seconds=timeout_fields["proxy_request_budget_seconds"][0],
1458
+ clear_proxy_request_budget_seconds=timeout_fields["proxy_request_budget_seconds"][1],
1459
+ compact_request_budget_seconds=timeout_fields["compact_request_budget_seconds"][0],
1460
+ clear_compact_request_budget_seconds=timeout_fields["compact_request_budget_seconds"][1],
1461
+ transcription_request_budget_seconds=timeout_fields["transcription_request_budget_seconds"][0],
1462
+ clear_transcription_request_budget_seconds=timeout_fields["transcription_request_budget_seconds"][1],
1463
+ stream_idle_timeout_seconds=timeout_fields["stream_idle_timeout_seconds"][0],
1464
+ clear_stream_idle_timeout_seconds=timeout_fields["stream_idle_timeout_seconds"][1],
1465
+ proxy_downstream_websocket_idle_timeout_seconds=timeout_fields[
1466
+ "proxy_downstream_websocket_idle_timeout_seconds"
1467
+ ][0],
1468
+ clear_proxy_downstream_websocket_idle_timeout_seconds=timeout_fields[
1469
+ "proxy_downstream_websocket_idle_timeout_seconds"
1470
+ ][1],
1471
+ sse_keepalive_interval_seconds=timeout_fields["sse_keepalive_interval_seconds"][0],
1472
+ clear_sse_keepalive_interval_seconds=timeout_fields["sse_keepalive_interval_seconds"][1],
1473
+ # end C2-1 timeouts
1474
+ # M1 stream/bridge budgets (registered in DASHBOARD_TIMEOUT_SETTINGS,
1475
+ # so the PUT-time invariant check and the audit loop cover them).
1476
+ http_responses_stream_request_budget_seconds=timeout_fields[
1477
+ "http_responses_stream_request_budget_seconds"
1478
+ ][0],
1479
+ clear_http_responses_stream_request_budget_seconds=timeout_fields[
1480
+ "http_responses_stream_request_budget_seconds"
1481
+ ][1],
1482
+ http_responses_session_bridge_request_budget_seconds=timeout_fields[
1483
+ "http_responses_session_bridge_request_budget_seconds"
1484
+ ][0],
1485
+ clear_http_responses_session_bridge_request_budget_seconds=timeout_fields[
1486
+ "http_responses_session_bridge_request_budget_seconds"
1487
+ ][1],
1488
+ # end M1 stream/bridge budgets
1489
+ ),
1490
+ # CAS anchor: omitted fields above were merged from `current`
1491
+ # (version checked against expectedVersion when supplied), so the
1492
+ # repository must apply the UPDATE only if the row still carries
1493
+ # that version; a writer committing in between yields 409 instead
1494
+ # of silently reverting its fields.
1495
+ actor_user_id=principal.user_id,
1496
+ expected_version=current.version,
1497
+ )
1498
+ except BreakGlassRequiresTotpError as exc:
1499
+ # Before the generic ValueError arm: this refusal is a 409 whose body
1500
+ # names the account that would clear it, not a malformed-input 400.
1501
+ raise DashboardConflictError(
1502
+ str(exc),
1503
+ code="break_glass_requires_totp",
1504
+ param=exc.username,
1505
+ details={"username": exc.username},
1506
+ ) from exc
1507
+ except ValueError as exc:
1508
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_config") from exc
1509
+
1510
+ upstream_route_inputs_changed = (
1511
+ current.upstream_proxy_routing_enabled != updated.upstream_proxy_routing_enabled
1512
+ or current.upstream_proxy_default_pool_id != updated.upstream_proxy_default_pool_id
1513
+ )
1514
+ # ``SettingsRepository.commit_refresh`` already cleared the route cache
1515
+ # synchronously between the commit and its refresh await (no concurrent
1516
+ # request can see the committed row alongside the stale cache); only the
1517
+ # durable cross-replica signals remain here.
1518
+ await get_settings_cache().invalidate()
1519
+ changed_fields = [
1520
+ field_name
1521
+ for field_name in (
1522
+ "sticky_threads_enabled",
1523
+ "upstream_stream_transport",
1524
+ "prohibit_fast_mode",
1525
+ "http_downstream_transport_policy",
1526
+ "thread_cache_identity_mode",
1527
+ "proxy_account_response_create_limit",
1528
+ "proxy_account_stream_limit",
1529
+ "proxy_account_stream_recovery_reserve",
1530
+ "proxy_api_key_fair_share_congestion_threshold_pct",
1531
+ *_ROUTING_OVERLOAD_SETTINGS, # C2-2 routing/overload
1532
+ "upstream_proxy_routing_enabled",
1533
+ "upstream_proxy_default_pool_id",
1534
+ "prefer_earlier_reset_accounts",
1535
+ "prefer_earlier_reset_window",
1536
+ "show_reset_credit_badges",
1537
+ "auto_redeem_reset_credits_before_expiry",
1538
+ "show_reset_credit_expiry_badge",
1539
+ "routing_strategy",
1540
+ "relative_availability_power",
1541
+ "relative_availability_top_k",
1542
+ "single_account_id",
1543
+ "openai_cache_affinity_max_age_seconds",
1544
+ "dashboard_session_ttl_seconds",
1545
+ "http_responses_session_bridge_prompt_cache_idle_ttl_seconds",
1546
+ "http_responses_session_bridge_gateway_safe_mode",
1547
+ "http_responses_session_bridge_codex_prewarm_enabled", # M3 codex prewarm
1548
+ "sticky_reallocation_budget_threshold_pct",
1549
+ "sticky_reallocation_primary_budget_threshold_pct",
1550
+ "sticky_reallocation_secondary_budget_threshold_pct",
1551
+ "additional_quota_routing_policies",
1552
+ "warmup_model",
1553
+ "import_without_overwrite",
1554
+ "totp_required_on_login",
1555
+ "totp_required_for_admin_role",
1556
+ "local_login_policy",
1557
+ "api_key_auth_enabled",
1558
+ "hide_upstream_quota_from_api_keys",
1559
+ "limit_warmup_enabled",
1560
+ "limit_warmup_windows",
1561
+ "limit_warmup_model",
1562
+ "limit_warmup_prompt",
1563
+ "limit_warmup_cooldown_seconds",
1564
+ "limit_warmup_exhausted_threshold_percent",
1565
+ "limit_warmup_idle_threshold_percent",
1566
+ "limit_warmup_min_available_percent",
1567
+ "weekly_pace_working_days",
1568
+ "weekly_pace_smoothing_minutes",
1569
+ "guest_access_enabled",
1570
+ "limit_warmup_staggered_idle_enabled",
1571
+ "request_log_retention_override_days",
1572
+ "usage_history_retention_override_days",
1573
+ # C2-3 resilience toggles
1574
+ "soft_drain_enabled",
1575
+ "deterministic_failover_enabled",
1576
+ "circuit_breaker_enabled",
1577
+ *BACKGROUND_JOB_SETTINGS, # M2 background jobs
1578
+ CONVERSATION_ARCHIVE_SETTING, # M5 conversation archive
1579
+ OPERATION_SPOOL_RETENTION_SETTING, # R2 spool retention
1580
+ *DASHBOARD_TIMEOUT_SETTINGS, # C2-1 timeouts
1581
+ )
1582
+ if getattr(current, field_name) != getattr(updated, field_name)
1583
+ ]
1584
+ # C2-3 resilience toggles / M2 background jobs / M5 conversation archive:
1585
+ # storing the inherited value (or clearing it) changes ownership without
1586
+ # changing the effective value; audit that too. An effective archive flip
1587
+ # additionally gets its own audit line naming the actor (further below).
1588
+ for field_name in (
1589
+ *RESILIENCE_TOGGLE_SETTINGS,
1590
+ *BACKGROUND_JOB_SETTINGS,
1591
+ CONVERSATION_ARCHIVE_SETTING,
1592
+ OPERATION_SPOOL_RETENTION_SETTING, # R2 spool retention
1593
+ ):
1594
+ if current.provenance[field_name] != updated.provenance[field_name] and field_name not in changed_fields:
1595
+ changed_fields.append(field_name)
1596
+ # C2-1 timeouts: a dashboard value equal to the inherited one still changes
1597
+ # the setting's owner (provenance source), which the audit log must record.
1598
+ for field_name in DASHBOARD_TIMEOUT_SETTINGS:
1599
+ if (
1600
+ field_name not in changed_fields
1601
+ and current.provenance[field_name].source != updated.provenance[field_name].source
1602
+ ):
1603
+ changed_fields.append(field_name)
1604
+ capacity_override_fields = (
1605
+ "proxy_account_response_create_limit",
1606
+ "proxy_account_stream_limit",
1607
+ "proxy_account_stream_recovery_reserve",
1608
+ "proxy_api_key_fair_share_congestion_threshold_pct",
1609
+ )
1610
+ for field_name in capacity_override_fields:
1611
+ override_field_name = f"{field_name}_override"
1612
+ if (
1613
+ getattr(current, field_name) == getattr(updated, field_name)
1614
+ and getattr(current, override_field_name) != getattr(updated, override_field_name)
1615
+ and field_name not in changed_fields
1616
+ ):
1617
+ changed_fields.append(field_name)
1618
+ # C2-2 routing/overload: a value that moved between layers without changing
1619
+ # the effective number (dashboard set to the inherited value, or cleared)
1620
+ # is still a change worth auditing.
1621
+ for field_name in _ROUTING_OVERLOAD_SETTINGS:
1622
+ if field_name not in changed_fields and current.provenance.get(field_name) != updated.provenance.get(
1623
+ field_name
1624
+ ):
1625
+ changed_fields.append(field_name)
1626
+ # end C2-2 routing/overload
1627
+ # M3 codex prewarm: storing the inherited value (or clearing it) changes
1628
+ # ownership without changing the effective value; audit that too.
1629
+ if "http_responses_session_bridge_codex_prewarm_enabled" not in changed_fields and current.provenance.get(
1630
+ "http_responses_session_bridge_codex_prewarm_enabled"
1631
+ ) != updated.provenance.get("http_responses_session_bridge_codex_prewarm_enabled"):
1632
+ changed_fields.append("http_responses_session_bridge_codex_prewarm_enabled")
1633
+ # end M3 codex prewarm
1634
+ if upstream_route_inputs_changed:
1635
+ # Durably bump ``upstream_route`` (with the coalesced retry fallback)
1636
+ # rather than relying solely on the ``settings`` bump issued above:
1637
+ # that bump is non-raising and enqueues no retry, so a transient write
1638
+ # failure would leave peers on the stale route outcome until the TTL
1639
+ # instead of the first recovered poll cycle. The re-clear inside
1640
+ # ``invalidate`` is harmless; the guarding clear already ran pre-await.
1641
+ await get_upstream_route_cache().invalidate()
1642
+ actor_ip = request.client.host if request.client else None
1643
+ AuditService.log_async(
1644
+ "settings_changed",
1645
+ actor_ip=actor_ip,
1646
+ actor=AuditActor.from_principal(principal),
1647
+ target=AuditTarget("settings", "dashboard"),
1648
+ details={"changed_fields": changed_fields},
1649
+ )
1650
+ if current.local_login_policy != updated.local_login_policy:
1651
+ # Who may use the local password form is a security decision with its
1652
+ # own event: ``settings_changed`` lists field names, this names values.
1653
+ AuditService.log_async(
1654
+ "login_policy_changed",
1655
+ actor_ip=actor_ip,
1656
+ actor=AuditActor.from_principal(principal),
1657
+ target=AuditTarget("settings", "local_login_policy"),
1658
+ details={"from": current.local_login_policy, "to": updated.local_login_policy},
1659
+ severity=AuditSeverity.WARNING,
1660
+ )
1661
+ # M5 conversation archive: enabling turns the proxy into a full
1662
+ # prompt/response recorder readable by the same dashboard admin, so every
1663
+ # effective on/off change is a dedicated audit event with the actor, not
1664
+ # just an entry in ``changed_fields``.
1665
+ if current.conversation_archive_enabled != updated.conversation_archive_enabled:
1666
+ AuditService.log_async(
1667
+ CONVERSATION_ARCHIVE_TOGGLED_ACTION,
1668
+ actor_ip=actor_ip,
1669
+ details={
1670
+ "enabled": updated.conversation_archive_enabled,
1671
+ "source": updated.provenance[CONVERSATION_ARCHIVE_SETTING].source,
1672
+ "actor": principal.actor,
1673
+ "actor_role": principal.role.value,
1674
+ },
1675
+ )
1676
+ # end M5 conversation archive
1677
+ return _dashboard_settings_response(updated, principal=principal)