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,838 @@
1
+ """The OpenID Connect provider: Authorization Code with PKCE, and nothing else.
2
+
3
+ This is the second implementation of the :class:`AuthProvider` protocol. Like
4
+ the first it ends in the shared identity resolver: it turns a *verified* ID
5
+ token into an :class:`ExternalIdentity` and stops. It creates no account, links
6
+ nothing, re-evaluates nothing and writes none of the identity audit events the
7
+ resolver already writes.
8
+
9
+ Three rules carry most of the security weight here, and each is written as code
10
+ rather than left to a library's defaults:
11
+
12
+ * **The signing key is chosen before the algorithm.** The candidate key is
13
+ selected from the cached key set by ``kid``; the permitted algorithms are
14
+ derived from *that key's own type*, intersected with what the identity
15
+ provider advertises and with a fixed allow-list. The token header's ``alg``
16
+ is never an input, which is what makes ``alg: none`` and "verify this RS256
17
+ token as HS256 with the public key as the HMAC secret" impossible rather than
18
+ merely refused.
19
+ * **Key rotation is a non-event.** Keys are cached with the discovery document
20
+ for fifteen minutes; a token whose ``kid`` is unknown forces exactly one
21
+ refresh, behind a per-provider cooldown so forged ``kid`` values cannot turn
22
+ this endpoint into a request amplifier aimed at the identity provider.
23
+ * **The operator's issuer is a URL this server fetches**, so every fetch is
24
+ ``https``, bounded in time and size, refuses redirects, and requires the
25
+ document's own ``issuer`` claim to equal the configured issuer exactly.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import hmac
32
+ import json
33
+ import logging
34
+ import time
35
+ from base64 import b64encode
36
+ from dataclasses import dataclass
37
+ from typing import Any, Final
38
+ from urllib.parse import quote, urlencode
39
+
40
+ import aiohttp
41
+ import anyio
42
+ import jwt
43
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
44
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
45
+ from fastapi import Request
46
+ from jwt.algorithms import ECAlgorithm, RSAAlgorithm
47
+
48
+ from app.core.auth.providers import (
49
+ DEFAULT_PROVIDER_KEY,
50
+ MAX_DISPLAY_NAME_LENGTH,
51
+ MAX_SUBJECT_LENGTH,
52
+ ExternalIdentity,
53
+ )
54
+ from app.core.clients.http import lease_http_session
55
+ from app.db.models import AuthProviderKind, DashboardAuthProvider
56
+ from app.modules.auth_providers.config import OidcProviderConfig, oidc_config_fingerprint, validate_https_url
57
+ from app.modules.dashboard_users.repository import is_valid_email, normalize_email
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ #: How far apart this server's clock and the identity provider's may be before
62
+ #: a token is refused. A tolerance is not something an operator tunes
63
+ #: (PRINCIPLES P2, the ``OAUTH_TIMEOUT_SECONDS`` precedent), so it is a
64
+ #: constant: 60 s covers unsynchronised clocks without widening the window a
65
+ #: captured token is usable in, which the independent ``iat``-versus-flow rule
66
+ #: below bounds anyway.
67
+ OIDC_CLOCK_SKEW_SECONDS: Final[int] = 60
68
+
69
+ #: Every server-to-server fetch this module makes. The shared client session is
70
+ #: built with ``total=None``, so a call site that brings no timeout has none.
71
+ _FETCH_TIMEOUT_SECONDS: Final[float] = 10.0
72
+ _MAX_DOCUMENT_BYTES: Final[int] = 256 * 1024
73
+ _MAX_JWKS_KEYS: Final[int] = 20
74
+ _METADATA_TTL_SECONDS: Final[float] = 900.0
75
+ #: A ``kid`` this replica has never seen forces one refetch, then waits.
76
+ _REFRESH_COOLDOWN_SECONDS: Final[float] = 60.0
77
+ #: How long the last good key set keeps serving while refreshes fail. An
78
+ #: identity provider's five-minute outage must not lock everyone out; a key
79
+ #: revoked after a compromise must not keep validating forever.
80
+ _STALE_METADATA_MAX_AGE_SECONDS: Final[float] = 24 * 60 * 60
81
+ #: How long a *failed* refresh suppresses the next one while the cached key set
82
+ #: is still being served. Without it an identity provider whose discovery
83
+ #: endpoint times out while its token endpoint is healthy costs every start and
84
+ #: every callback another ten seconds, serialised behind the cache lock: the
85
+ #: outage of one endpoint becomes an outage of the whole sign-in path.
86
+ _FAILED_REFRESH_BACKOFF_SECONDS: Final[float] = 60.0
87
+
88
+ #: Asymmetric signature algorithms only. Every MAC algorithm and ``none`` is
89
+ #: absent by construction, not by a check that could be reordered away.
90
+ _SIGNING_ALGORITHMS: Final[frozenset[str]] = frozenset(
91
+ {"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512"}
92
+ )
93
+ _RSA_ALGORITHMS: Final[tuple[str, ...]] = ("RS256", "RS384", "RS512", "PS256", "PS384", "PS512")
94
+ _EC_ALGORITHMS: Final[dict[str, tuple[str, ...]]] = {
95
+ "P-256": ("ES256",),
96
+ "P-384": ("ES384",),
97
+ "P-521": ("ES512",),
98
+ }
99
+
100
+ #: What the authorization request asks for. Group claims that need a scope are
101
+ #: configured at the identity provider against this client, not here: the
102
+ #: connection document names the claim, not the scope.
103
+ _SCOPE: Final[str] = "openid email profile"
104
+
105
+ __all__ = [
106
+ "OIDC_CLOCK_SKEW_SECONDS",
107
+ "OidcCompletion",
108
+ "OidcError",
109
+ "OidcExchangeError",
110
+ "OidcMetadata",
111
+ "OidcMetadataError",
112
+ "OidcProvider",
113
+ "OidcTokenError",
114
+ "get_oidc_provider",
115
+ ]
116
+
117
+
118
+ class OidcError(Exception):
119
+ """A sign-in that cannot continue. ``stage`` is the coarse audit label.
120
+
121
+ Never carries a value from the identity provider: the message is ours, the
122
+ stage names where we were, and neither reaches the browser.
123
+ """
124
+
125
+ stage = "oidc"
126
+
127
+ def __init__(self, message: str, *, stage: str | None = None) -> None:
128
+ super().__init__(message)
129
+ if stage is not None:
130
+ self.stage = stage
131
+
132
+
133
+ class OidcMetadataError(OidcError):
134
+ """The discovery document or the key set could not be trusted."""
135
+
136
+ stage = "metadata"
137
+
138
+
139
+ class OidcExchangeError(OidcError):
140
+ """The authorization code could not be exchanged."""
141
+
142
+ stage = "exchange"
143
+
144
+
145
+ class OidcTokenError(OidcError):
146
+ """The ID token did not verify, or did not carry what this flow asked for."""
147
+
148
+ stage = "id_token"
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class OidcMetadata:
153
+ """The discovery document and its key set, fetched and cached together."""
154
+
155
+ issuer: str
156
+ authorization_endpoint: str
157
+ token_endpoint: str
158
+ jwks_uri: str
159
+ signing_algorithms: tuple[str, ...]
160
+ token_auth_method: str
161
+ keys: tuple[dict[str, Any], ...]
162
+
163
+ def key_for(self, kid: str | None) -> dict[str, Any] | None:
164
+ """The one candidate key, or ``None`` when the set does not name it.
165
+
166
+ A ``kid``-less token is only served when the set holds exactly one key;
167
+ guessing among several is how a retired key gets used.
168
+ """
169
+
170
+ if kid is not None:
171
+ for key in self.keys:
172
+ if key.get("kid") == kid:
173
+ return key
174
+ return None
175
+ return self.keys[0] if len(self.keys) == 1 else None
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class OidcCompletion:
180
+ """What a completed round trip proved: an identity, and when it was proved.
181
+
182
+ ``auth_time`` is the identity provider's own statement of when the person
183
+ last authenticated; the step-up flow requires it, ordinary sign-in ignores
184
+ it. The ID token itself never leaves this module.
185
+ """
186
+
187
+ identity: ExternalIdentity
188
+ auth_time: int | None
189
+
190
+
191
+ @dataclass(slots=True)
192
+ class _CacheEntry:
193
+ metadata: OidcMetadata
194
+ #: When the key set this entry holds was last fetched *successfully*. Both
195
+ #: the positive lifetime and the 24-hour trust ceiling are measured from it,
196
+ #: which is why a failed refresh must never move it.
197
+ fetched_at: float
198
+ forced_refresh_at: float
199
+ #: When a refresh last failed, or ``0.0`` since the last success. Held
200
+ #: apart from ``fetched_at`` so backing off a broken discovery endpoint
201
+ #: cannot extend how long keys of unknown age stay trusted.
202
+ failed_refresh_at: float = 0.0
203
+
204
+ def is_fresh(self, now: float) -> bool:
205
+ return now - self.fetched_at < _METADATA_TTL_SECONDS
206
+
207
+ def is_trusted(self, now: float) -> bool:
208
+ return now - self.fetched_at <= _STALE_METADATA_MAX_AGE_SECONDS
209
+
210
+ def is_backing_off(self, now: float) -> bool:
211
+ return self.failed_refresh_at > 0.0 and now - self.failed_refresh_at < _FAILED_REFRESH_BACKOFF_SECONDS
212
+
213
+ def serves(self, now: float) -> bool:
214
+ """Whether this entry answers without attempting a fetch first.
215
+
216
+ Either it is inside its positive lifetime, or its last refresh failed
217
+ recently and it is still inside the trust ceiling -- in which case a
218
+ fetch would only queue another timeout behind the cache lock to reach
219
+ the same stale answer.
220
+ """
221
+
222
+ return self.is_fresh(now) or (self.is_backing_off(now) and self.is_trusted(now))
223
+
224
+
225
+ # --- fetching -------------------------------------------------------------
226
+
227
+
228
+ async def _read_bounded(content: aiohttp.StreamReader) -> bytes:
229
+ """Everything the response carries, up to one byte past the cap.
230
+
231
+ ``StreamReader.read(n)`` answers with what has *arrived*, not with ``n``
232
+ bytes: a discovery document split across two network writes comes back
233
+ truncated from a single call and then fails to parse as JSON. Reading to
234
+ EOF is what makes the parse deterministic; the cap still bounds it, because
235
+ the loop stops as soon as it holds one byte more than the caller will
236
+ accept, and the request's own total timeout still bounds the wait.
237
+ """
238
+
239
+ limit = _MAX_DOCUMENT_BYTES + 1
240
+ chunks: list[bytes] = []
241
+ received = 0
242
+ while received < limit:
243
+ chunk = await content.read(limit - received)
244
+ if not chunk:
245
+ break
246
+ chunks.append(chunk)
247
+ received += len(chunk)
248
+ return b"".join(chunks)
249
+
250
+
251
+ async def _fetch_json(url: str, *, stage: str) -> dict[str, Any]:
252
+ """One bounded, redirect-refusing, JSON-only GET against an operator URL."""
253
+
254
+ timeout = aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_SECONDS)
255
+ try:
256
+ async with lease_http_session() as session:
257
+ async with session.get(
258
+ url,
259
+ allow_redirects=False,
260
+ timeout=timeout,
261
+ headers={"Accept": "application/json"},
262
+ ) as response:
263
+ if response.status != 200:
264
+ # A redirect lands here too, deliberately: a redirect is how
265
+ # a host that passed validation becomes one that would not.
266
+ raise OidcMetadataError(f"fetch answered status {response.status}", stage=stage)
267
+ if not _is_json(response.content_type):
268
+ raise OidcMetadataError("fetch answered a non-JSON body", stage=stage)
269
+ body = await _read_bounded(response.content)
270
+ except OidcError:
271
+ raise
272
+ except Exception as exc: # network, TLS, timeout
273
+ raise OidcMetadataError("fetch failed", stage=stage) from exc
274
+ if len(body) > _MAX_DOCUMENT_BYTES:
275
+ raise OidcMetadataError("fetch answered an oversized body", stage=stage)
276
+ try:
277
+ document = json.loads(body)
278
+ except ValueError as exc:
279
+ raise OidcMetadataError("fetch answered invalid JSON", stage=stage) from exc
280
+ if not isinstance(document, dict):
281
+ raise OidcMetadataError("fetch answered a non-object body", stage=stage)
282
+ return document
283
+
284
+
285
+ def _is_json(content_type: str | None) -> bool:
286
+ value = (content_type or "").casefold()
287
+ return value == "application/json" or value.endswith("+json")
288
+
289
+
290
+ def _endpoint(document: dict[str, Any], field: str) -> str:
291
+ raw = document.get(field)
292
+ if not isinstance(raw, str):
293
+ raise OidcMetadataError(f"discovery document has no {field}")
294
+ try:
295
+ # The advertised endpoints pass the same scheme and host rules as the
296
+ # issuer, but are NOT required to share its origin: Google Workspace
297
+ # publishes its keys on a different host from its issuer, and a rule
298
+ # operators must switch off for a target identity provider is not a
299
+ # rule. The ``issuer``-claim equality check below is what makes the
300
+ # document trustworthy enough to name them.
301
+ return validate_https_url(raw, field=field)
302
+ except Exception as exc:
303
+ raise OidcMetadataError(f"discovery document's {field} is not a usable URL") from exc
304
+
305
+
306
+ async def fetch_metadata(config: OidcProviderConfig) -> OidcMetadata:
307
+ document = await _fetch_json(config.discovery_url, stage="discovery")
308
+ if document.get("issuer") != config.issuer:
309
+ # OIDC Discovery mandates this equality; it is what binds the returned
310
+ # document to the issuer the operator typed.
311
+ raise OidcMetadataError("discovery document names another issuer")
312
+ # Every advertised endpoint is validated before any of them is used.
313
+ jwks_uri = _endpoint(document, "jwks_uri")
314
+ authorization_endpoint = _endpoint(document, "authorization_endpoint")
315
+ token_endpoint = _endpoint(document, "token_endpoint")
316
+ advertised = document.get("id_token_signing_alg_values_supported")
317
+ signing_algorithms = (
318
+ tuple(value for value in advertised if isinstance(value, str)) if isinstance(advertised, list) else ()
319
+ )
320
+ auth_methods = document.get("token_endpoint_auth_methods_supported")
321
+ token_auth_method = (
322
+ "client_secret_post"
323
+ if isinstance(auth_methods, list) and "client_secret_post" in auth_methods
324
+ else "client_secret_basic"
325
+ )
326
+ keys = _signing_keys(await _fetch_json(jwks_uri, stage="jwks"))
327
+ return OidcMetadata(
328
+ issuer=config.issuer,
329
+ authorization_endpoint=authorization_endpoint,
330
+ token_endpoint=token_endpoint,
331
+ jwks_uri=jwks_uri,
332
+ signing_algorithms=signing_algorithms,
333
+ token_auth_method=token_auth_method,
334
+ keys=keys,
335
+ )
336
+
337
+
338
+ def _signing_keys(document: dict[str, Any]) -> tuple[dict[str, Any], ...]:
339
+ raw_keys = document.get("keys")
340
+ if not isinstance(raw_keys, list):
341
+ raise OidcMetadataError("key set has no keys", stage="jwks")
342
+ if len(raw_keys) > _MAX_JWKS_KEYS:
343
+ raise OidcMetadataError("key set is too large", stage="jwks")
344
+ keys = [key for key in raw_keys if isinstance(key, dict) and _is_verification_key(key)]
345
+ if not keys:
346
+ raise OidcMetadataError("key set holds no usable verification key", stage="jwks")
347
+ return tuple(keys)
348
+
349
+
350
+ def _is_verification_key(key: dict[str, Any]) -> bool:
351
+ if key.get("kty") not in {"RSA", "EC"}:
352
+ return False
353
+ if key.get("use") not in {None, "sig"}:
354
+ return False
355
+ key_ops = key.get("key_ops")
356
+ if isinstance(key_ops, list) and "verify" not in key_ops:
357
+ return False
358
+ # A key set that ships private material is malformed; refusing it keeps a
359
+ # private exponent from ever reaching the JWK parser.
360
+ return "d" not in key
361
+
362
+
363
+ # --- the cache ------------------------------------------------------------
364
+
365
+
366
+ class OidcMetadataCache:
367
+ """One entry per provider *configuration*, so a configuration write invalidates itself.
368
+
369
+ The key carries a digest of the connection document, not the row's
370
+ ``updated_at``: that column has one-second resolution on SQLite, so two
371
+ writes inside the same second would share a key and a repointed provider
372
+ would keep serving the previous issuer's endpoints -- which is how the new
373
+ client secret ends up posted to the old identity provider's token endpoint,
374
+ a leg that runs *before* the issuer check could refuse the answer. A digest
375
+ of what the flow will actually use has no such window.
376
+ """
377
+
378
+ def __init__(self) -> None:
379
+ self._entries: dict[tuple[str, str], _CacheEntry] = {}
380
+ self._lock = anyio.Lock()
381
+
382
+ @staticmethod
383
+ def _key(provider: DashboardAuthProvider, config: OidcProviderConfig) -> tuple[str, str]:
384
+ return (provider.id, oidc_config_fingerprint(config))
385
+
386
+ async def get(self, provider: DashboardAuthProvider, config: OidcProviderConfig) -> OidcMetadata:
387
+ key = self._key(provider, config)
388
+ now = time.monotonic()
389
+ entry = self._entries.get(key)
390
+ if entry is not None and entry.serves(now):
391
+ return entry.metadata
392
+ async with self._lock:
393
+ now = time.monotonic()
394
+ entry = self._entries.get(key)
395
+ if entry is not None and entry.serves(now):
396
+ return entry.metadata
397
+ return await self._fetch_locked(key, config, entry, now)
398
+
399
+ async def refresh_for_kid(
400
+ self, provider: DashboardAuthProvider, config: OidcProviderConfig, kid: str | None
401
+ ) -> OidcMetadata | None:
402
+ """One out-of-band refresh for an unknown ``kid``, or ``None`` while cooling down.
403
+
404
+ Without the cooldown, ID tokens carrying random ``kid`` values would be
405
+ a free request amplifier aimed at the identity provider; with it, a
406
+ published rotation still converges on the first token that uses the new
407
+ key.
408
+ """
409
+
410
+ key = self._key(provider, config)
411
+ async with self._lock:
412
+ now = time.monotonic()
413
+ entry = self._entries.get(key)
414
+ if entry is not None:
415
+ # Whoever held the lock before this caller may have refreshed
416
+ # for the very same rotation. Answering from what is cached now
417
+ # -- rather than from the cooldown, which that refresh set --
418
+ # is the difference between two callbacks meeting a rotated key
419
+ # and only the first of them signing in.
420
+ cached = entry.metadata.key_for(kid)
421
+ if cached is not None:
422
+ return entry.metadata
423
+ if now - entry.forced_refresh_at < _REFRESH_COOLDOWN_SECONDS:
424
+ return None
425
+ entry.forced_refresh_at = now
426
+ try:
427
+ metadata = await self._fetch_locked(key, config, entry, now, forced=True)
428
+ except OidcError:
429
+ return None
430
+ return metadata if metadata.key_for(kid) is not None else None
431
+
432
+ async def _fetch_locked(
433
+ self,
434
+ key: tuple[str, str],
435
+ config: OidcProviderConfig,
436
+ entry: _CacheEntry | None,
437
+ now: float,
438
+ *,
439
+ forced: bool = False,
440
+ ) -> OidcMetadata:
441
+ try:
442
+ metadata = await fetch_metadata(config)
443
+ except OidcError:
444
+ if entry is not None and entry.is_trusted(now):
445
+ # Serving the last good key set through a brief outage keeps
446
+ # this software from being less available than the identity
447
+ # provider it depends on. The cap is what keeps a key revoked
448
+ # after a compromise from being trusted indefinitely, and the
449
+ # backoff below is what keeps the outage from costing every
450
+ # request a fresh ten-second timeout behind this lock -- the
451
+ # stamp goes on its own field so the cap still counts from the
452
+ # last *successful* fetch.
453
+ entry.failed_refresh_at = now
454
+ logger.warning("OIDC metadata refresh failed; serving the cached key set")
455
+ return entry.metadata
456
+ raise
457
+ # One entry per provider, not per configuration: the key carries the
458
+ # document's digest so a write invalidates itself, and dropping the
459
+ # older entries of the same provider keeps a long-lived process from
460
+ # accumulating one entry per configuration edit it ever saw.
461
+ self._entries = {other: value for other, value in self._entries.items() if other[0] != key[0]}
462
+ self._entries[key] = _CacheEntry(
463
+ metadata=metadata,
464
+ fetched_at=now,
465
+ forced_refresh_at=now if forced else (entry.forced_refresh_at if entry is not None else 0.0),
466
+ )
467
+ return metadata
468
+
469
+ def clear(self) -> None:
470
+ self._entries.clear()
471
+
472
+
473
+ _metadata_cache = OidcMetadataCache()
474
+
475
+
476
+ def get_oidc_metadata_cache() -> OidcMetadataCache:
477
+ return _metadata_cache
478
+
479
+
480
+ # --- verification ---------------------------------------------------------
481
+
482
+
483
+ def _permitted_algorithms(key: dict[str, Any], advertised: tuple[str, ...]) -> tuple[str, ...]:
484
+ """What this key may have signed with -- derived from the key, never from the token."""
485
+
486
+ if key.get("kty") == "RSA":
487
+ family: tuple[str, ...] = _RSA_ALGORITHMS
488
+ elif key.get("kty") == "EC":
489
+ curve = key.get("crv")
490
+ family = _EC_ALGORITHMS.get(curve, ()) if isinstance(curve, str) else ()
491
+ else:
492
+ family = ()
493
+ declared = key.get("alg")
494
+ if isinstance(declared, str):
495
+ family = tuple(name for name in family if name == declared)
496
+ permitted = tuple(name for name in family if name in _SIGNING_ALGORITHMS)
497
+ if advertised:
498
+ permitted = tuple(name for name in permitted if name in advertised)
499
+ return permitted
500
+
501
+
502
+ def _public_key(key: dict[str, Any]) -> RSAPublicKey | EllipticCurvePublicKey:
503
+ serialized = json.dumps(key)
504
+ try:
505
+ if key.get("kty") == "RSA":
506
+ loaded = RSAAlgorithm.from_jwk(serialized)
507
+ else:
508
+ loaded = ECAlgorithm.from_jwk(serialized)
509
+ except Exception as exc:
510
+ raise OidcTokenError("signing key could not be parsed") from exc
511
+ if not isinstance(loaded, RSAPublicKey | EllipticCurvePublicKey):
512
+ raise OidcTokenError("signing key is not a public key")
513
+ return loaded
514
+
515
+
516
+ def verify_id_token(
517
+ id_token: str,
518
+ *,
519
+ config: OidcProviderConfig,
520
+ metadata: OidcMetadata,
521
+ key: dict[str, Any],
522
+ nonce_hash: str,
523
+ flow_created_at: int,
524
+ now: int,
525
+ ) -> dict[str, Any]:
526
+ """The verified claims, or :class:`OidcTokenError`. Nothing else reads a claim."""
527
+
528
+ algorithms = _permitted_algorithms(key, metadata.signing_algorithms)
529
+ if not algorithms:
530
+ raise OidcTokenError("no permitted algorithm for the selected signing key")
531
+ try:
532
+ claims = jwt.decode(
533
+ id_token,
534
+ key=_public_key(key),
535
+ algorithms=list(algorithms),
536
+ audience=config.client_id,
537
+ issuer=config.issuer,
538
+ leeway=OIDC_CLOCK_SKEW_SECONDS,
539
+ options={"require": ["iss", "aud", "exp", "iat", "sub"]},
540
+ )
541
+ except OidcError:
542
+ raise
543
+ except Exception as exc:
544
+ raise OidcTokenError("ID token did not verify") from exc
545
+
546
+ audience = claims.get("aud")
547
+ if isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != config.client_id:
548
+ raise OidcTokenError("ID token names several audiences without a matching azp")
549
+
550
+ issued_at = claims.get("iat")
551
+ if not isinstance(issued_at, int | float) or isinstance(issued_at, bool):
552
+ raise OidcTokenError("ID token has no usable iat")
553
+ if issued_at > now + OIDC_CLOCK_SKEW_SECONDS:
554
+ raise OidcTokenError("ID token was issued too far in the future")
555
+ # Independent of the skew tolerance: a token minted before this flow began
556
+ # is not the token this flow asked for, however distant its expiry. The
557
+ # flow lives ten minutes, so a token accepted here is minutes old.
558
+ if issued_at < flow_created_at - OIDC_CLOCK_SKEW_SECONDS:
559
+ raise OidcTokenError("ID token predates the flow it is answering")
560
+
561
+ nonce = claims.get("nonce")
562
+ if not isinstance(nonce, str) or not hmac.compare_digest(hash_flow_value(nonce), nonce_hash):
563
+ raise OidcTokenError("ID token carries the wrong nonce")
564
+ return claims
565
+
566
+
567
+ def hash_flow_value(value: str) -> str:
568
+ """The only form a ``state`` or a ``nonce`` is stored or rate-limited in."""
569
+
570
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
571
+
572
+
573
+ # --- claims to identity ---------------------------------------------------
574
+
575
+
576
+ def _groups(raw: object) -> tuple[str, ...]:
577
+ values = [raw] if isinstance(raw, str) else raw if isinstance(raw, list) else []
578
+ groups: list[str] = []
579
+ for value in values:
580
+ if not isinstance(value, str):
581
+ continue
582
+ folded = value.strip().casefold()
583
+ if folded and folded not in groups:
584
+ groups.append(folded)
585
+ return tuple(groups)
586
+
587
+
588
+ def _claims_email_verified(claims: dict[str, Any]) -> bool:
589
+ """Whether the identity provider *affirmed* the address, for a claim that is present.
590
+
591
+ Only the JSON boolean ``true`` and the string ``"true"`` count. The string
592
+ spelling is not tolerance for sloppiness: Google's documented ID token
593
+ carries ``"email_verified": "true"``, Apple documents the claim as either a
594
+ string or a boolean, and Cognito emits strings for attributes it maps from
595
+ an upstream provider -- so an identity check that accepts only ``True``
596
+ would drop the address of every legitimately verified user at three of the
597
+ identity providers this feature exists for.
598
+
599
+ Everything else present is false, including ``"false"``, ``0``, ``null`` and
600
+ a container. This is the one claim in this module that used to fail open,
601
+ and the value it guards is the one the resolver links accounts by.
602
+ """
603
+
604
+ value = claims.get("email_verified")
605
+ if isinstance(value, bool):
606
+ return value
607
+ return isinstance(value, str) and value.strip().casefold() == "true"
608
+
609
+
610
+ def identity_from_claims(claims: dict[str, Any], config: OidcProviderConfig) -> ExternalIdentity | None:
611
+ """``None`` when the token names nobody this system can hold as an identity."""
612
+
613
+ subject = claims.get(config.subject_claim)
614
+ # Verbatim: not case-folded (an OIDC ``sub`` is case-sensitive by
615
+ # specification, and folding would merge two distinct subjects at an
616
+ # identity provider that issues both spellings) and not trimmed either.
617
+ # Trimming is the same defect one character over: an identity provider that
618
+ # issues ``alice`` and ``<space>alice<space>`` as two subjects would have
619
+ # them collapse onto one identity here, and the second person would be
620
+ # signed into the first's account. Emptiness and length are *checked*
621
+ # against the stripped and the raw value; neither check rewrites the
622
+ # identifier that is looked up.
623
+ if not isinstance(subject, str) or not subject.strip() or len(subject) > MAX_SUBJECT_LENGTH:
624
+ return None
625
+
626
+ email: str | None = None
627
+ # ``email_verified`` present and not affirmative means the identity provider
628
+ # is telling us the address is self-asserted. The resolver would use it for
629
+ # e-mail linking, for the new account's address and for ``email_domain``
630
+ # role rules -- the last of which is a role escalation. Absent is not false:
631
+ # many providers omit the claim, and linking is off by default.
632
+ if "email_verified" not in claims or _claims_email_verified(claims):
633
+ candidate = claims.get(config.email_claim)
634
+ if isinstance(candidate, str):
635
+ normalized = normalize_email(candidate)
636
+ email = normalized if normalized is not None and is_valid_email(normalized) else None
637
+
638
+ raw_name = claims.get(config.name_claim)
639
+ display_name = (
640
+ raw_name.strip()[:MAX_DISPLAY_NAME_LENGTH] if isinstance(raw_name, str) and raw_name.strip() else None
641
+ )
642
+
643
+ return ExternalIdentity(
644
+ provider=AuthProviderKind.OIDC.value,
645
+ provider_key=DEFAULT_PROVIDER_KEY,
646
+ subject=subject,
647
+ email=email,
648
+ display_name=display_name,
649
+ groups=_groups(claims.get(config.groups_claim)),
650
+ )
651
+
652
+
653
+ # --- the provider ---------------------------------------------------------
654
+
655
+
656
+ class OidcProvider:
657
+ """Redirect-style sign-in at an operator-configured identity provider.
658
+
659
+ ``resolve_identity`` returns nothing because a bare request carries no
660
+ evidence this provider recognises; the round trip is ``begin_login`` (the
661
+ authorization URL) and ``complete_login`` (the code exchange and the ID
662
+ token), both driven by the public routes in ``dashboard_auth``.
663
+ """
664
+
665
+ kind = AuthProviderKind.OIDC
666
+ provider_key = DEFAULT_PROVIDER_KEY
667
+
668
+ def resolve_identity(self, request: Request) -> ExternalIdentity | None:
669
+ del request
670
+ return None
671
+
672
+ async def begin_login(
673
+ self,
674
+ *,
675
+ provider: DashboardAuthProvider,
676
+ config: OidcProviderConfig,
677
+ state: str,
678
+ nonce: str,
679
+ code_challenge: str,
680
+ prompt_login: bool = False,
681
+ ) -> str:
682
+ """The authorization URL to send the browser to. PKCE is always S256."""
683
+
684
+ metadata = await get_oidc_metadata_cache().get(provider, config)
685
+ parameters = [
686
+ ("response_type", "code"),
687
+ ("client_id", config.client_id),
688
+ ("redirect_uri", config.redirect_uri),
689
+ ("scope", _SCOPE),
690
+ ("state", state),
691
+ ("nonce", nonce),
692
+ ("code_challenge", code_challenge),
693
+ ("code_challenge_method", "S256"),
694
+ ]
695
+ if prompt_login:
696
+ # Re-authentication as a step-up factor: ask for a fresh sign-in and
697
+ # for the identity provider to say when it happened.
698
+ parameters += [("prompt", "login"), ("max_age", "0")]
699
+ separator = "&" if "?" in metadata.authorization_endpoint else "?"
700
+ return metadata.authorization_endpoint + separator + urlencode(parameters)
701
+
702
+ async def complete_login(
703
+ self,
704
+ *,
705
+ provider: DashboardAuthProvider,
706
+ config: OidcProviderConfig,
707
+ code: str,
708
+ code_verifier: str,
709
+ redirect_uri: str,
710
+ nonce_hash: str,
711
+ flow_created_at: int,
712
+ now: int | None = None,
713
+ ) -> OidcCompletion:
714
+ """Exchange the code once, verify the ID token, and build the identity."""
715
+
716
+ metadata = await get_oidc_metadata_cache().get(provider, config)
717
+ id_token = await self._exchange(
718
+ config=config,
719
+ metadata=metadata,
720
+ code=code,
721
+ code_verifier=code_verifier,
722
+ redirect_uri=redirect_uri,
723
+ )
724
+ key, metadata = await self._select_key(provider, config, metadata, id_token)
725
+ claims = verify_id_token(
726
+ id_token,
727
+ config=config,
728
+ metadata=metadata,
729
+ key=key,
730
+ nonce_hash=nonce_hash,
731
+ flow_created_at=flow_created_at,
732
+ now=int(time.time()) if now is None else now,
733
+ )
734
+ identity = identity_from_claims(claims, config)
735
+ if identity is None:
736
+ raise OidcTokenError("ID token names no usable subject")
737
+ auth_time = claims.get("auth_time")
738
+ return OidcCompletion(
739
+ identity=identity,
740
+ auth_time=int(auth_time)
741
+ if isinstance(auth_time, int | float) and not isinstance(auth_time, bool)
742
+ else None,
743
+ )
744
+
745
+ async def _select_key(
746
+ self,
747
+ provider: DashboardAuthProvider,
748
+ config: OidcProviderConfig,
749
+ metadata: OidcMetadata,
750
+ id_token: str,
751
+ ) -> tuple[dict[str, Any], OidcMetadata]:
752
+ try:
753
+ header = jwt.get_unverified_header(id_token)
754
+ except Exception as exc:
755
+ raise OidcTokenError("ID token header could not be read") from exc
756
+ raw_kid = header.get("kid")
757
+ kid = raw_kid if isinstance(raw_kid, str) else None
758
+ key = metadata.key_for(kid)
759
+ if key is None:
760
+ refreshed = await get_oidc_metadata_cache().refresh_for_kid(provider, config, kid)
761
+ if refreshed is None:
762
+ raise OidcTokenError("ID token names an unknown signing key")
763
+ metadata = refreshed
764
+ key = metadata.key_for(kid)
765
+ if key is None: # pragma: no cover - refresh_for_kid already checked
766
+ raise OidcTokenError("ID token names an unknown signing key")
767
+ return key, metadata
768
+
769
+ async def _exchange(
770
+ self,
771
+ *,
772
+ config: OidcProviderConfig,
773
+ metadata: OidcMetadata,
774
+ code: str,
775
+ code_verifier: str,
776
+ redirect_uri: str,
777
+ ) -> str:
778
+ """One token request, on the non-retrying session.
779
+
780
+ The flow row is already consumed by the time this runs, so a replayed
781
+ authorization code never reaches the token endpoint twice through this
782
+ system -- and for the same reason this request is never retried.
783
+ """
784
+
785
+ form = {
786
+ "grant_type": "authorization_code",
787
+ "code": code,
788
+ "redirect_uri": redirect_uri,
789
+ "client_id": config.client_id,
790
+ "code_verifier": code_verifier,
791
+ }
792
+ headers = {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"}
793
+ if metadata.token_auth_method == "client_secret_post":
794
+ form["client_secret"] = config.client_secret
795
+ else:
796
+ # RFC 6749 §2.3.1: both halves are form-urlencoded before they are
797
+ # joined and base64ed. Assembled from a list so no line of this
798
+ # file ever reads as a literal credential pair.
799
+ parts = [quote(config.client_id, safe=""), quote(config.client_secret, safe="")]
800
+ headers["Authorization"] = "Basic " + b64encode(":".join(parts).encode()).decode("ascii")
801
+ timeout = aiohttp.ClientTimeout(total=_FETCH_TIMEOUT_SECONDS)
802
+ try:
803
+ async with lease_http_session() as session:
804
+ async with session.post(
805
+ metadata.token_endpoint,
806
+ data=form,
807
+ headers=headers,
808
+ allow_redirects=False,
809
+ timeout=timeout,
810
+ ) as response:
811
+ status = response.status
812
+ # Read to EOF, not "whatever arrived": a token response
813
+ # split across two writes would otherwise fail to parse and
814
+ # burn an authorization code that was perfectly good, and
815
+ # the code cannot be presented twice.
816
+ body = await _read_bounded(response.content)
817
+ except Exception as exc:
818
+ raise OidcExchangeError("token request failed") from exc
819
+ if status != 200 or len(body) > _MAX_DOCUMENT_BYTES:
820
+ # The identity provider's ``error_description`` is deliberately not
821
+ # read: nothing from it reaches a response, a log line or an audit
822
+ # row, and neither does the request body that carried the secret.
823
+ raise OidcExchangeError("token request was refused")
824
+ try:
825
+ payload = json.loads(body)
826
+ except ValueError as exc:
827
+ raise OidcExchangeError("token response was not JSON") from exc
828
+ id_token = payload.get("id_token") if isinstance(payload, dict) else None
829
+ if not isinstance(id_token, str) or not id_token:
830
+ raise OidcExchangeError("token response carried no ID token")
831
+ return id_token
832
+
833
+
834
+ _provider = OidcProvider()
835
+
836
+
837
+ def get_oidc_provider() -> OidcProvider:
838
+ return _provider