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,1435 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
7
+ from contextlib import AbstractContextManager, AsyncExitStack, nullcontext
8
+ from dataclasses import dataclass, field
9
+ from json import JSONDecodeError
10
+ from math import isfinite
11
+ from typing import Any, Literal, cast
12
+
13
+ import aiohttp
14
+
15
+ from app.core.clients.http import lease_model_source_session
16
+ from app.core.clock import REAL_CLOCK, REAL_SCHEDULER, Clock, Scheduler
17
+ from app.core.config.dashboard_overrides import with_dashboard_overrides
18
+ from app.core.config.settings import get_settings
19
+ from app.core.crypto import TokenEncryptor
20
+ from app.core.openai.parsing import classify_event_type
21
+ from app.core.types import JsonValue
22
+ from app.core.utils.json_guards import is_json_mapping
23
+ from app.core.utils.shared_future import (
24
+ _await_cleanup_deferring_cancellation as _shared_await_cleanup_deferring_cancellation,
25
+ )
26
+ from app.core.utils.shared_future import _await_task_deferring_cancellation
27
+ from app.core.utils.sse import extract_sse_data
28
+ from app.db.models import ModelSource
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _DEFAULT_SOURCE_TIMEOUT_SECONDS = 600
33
+
34
+ # Bounded exposure for OpenAI-compatible model-source transport (#2123 WP-C1,
35
+ # design v3 §3, §8.2). Single definition: callers import, never re-literal.
36
+ SOURCE_CONNECT_DEADLINE_SECONDS = 10.0
37
+ # Responses streams only (``stream_responses``): a compliant source emits
38
+ # ``response.created`` within milliseconds of its headers, so headers-then-
39
+ # queue is exactly what these two catch. Chat-completions streams have no
40
+ # bookkeeping frame -- the first byte is the first token, and OpenAI-compatible
41
+ # local servers (llama.cpp, vLLM, Ollama) send it, often together with their
42
+ # headers, only after prompt processing -- so their open returns at the headers
43
+ # as before the hardening and the body reads the first token under the
44
+ # source's total budget alone.
45
+ SOURCE_HEADER_DEADLINE_SECONDS = 20.0
46
+ # Time from response headers to the first body chunk.
47
+ SOURCE_FIRST_FRAME_DEADLINE_SECONDS = 30.0
48
+ # Mid-stream silence cap; ``source_stream_idle_seconds()`` takes the minimum
49
+ # with ``settings.stream_idle_timeout_seconds`` so a source never inherits the
50
+ # 7200 s subscription idle window.
51
+ SOURCE_STREAM_IDLE_CAP_SECONDS = 300.0
52
+ # Aggregate bytes a stream may withhold while a pre-content hook is armed
53
+ # (``_source_stream_body``): twice the parser's single-frame cap, so one
54
+ # oversized frame always reaches the parser's content classification before
55
+ # this bound trips, while a source that only ever produces bookkeeping frames
56
+ # cannot grow the withheld buffer for its whole total budget.
57
+ SOURCE_STREAM_WITHHELD_CAP_BYTES = 2 * 1_048_576
58
+
59
+ TimeoutPhase = Literal["connect", "header", "first_frame", "idle"]
60
+ FrameKind = Literal["non_content", "content", "success_terminal", "failure_terminal"]
61
+
62
+
63
+ class ModelSourceForwardingError(Exception):
64
+ def __init__(
65
+ self,
66
+ *,
67
+ status_code: int,
68
+ payload: dict[str, JsonValue],
69
+ upstream_status_code: int | None = None,
70
+ retry_after: str | None = None,
71
+ timeout_phase: TimeoutPhase | None = None,
72
+ ) -> None:
73
+ super().__init__(str(payload))
74
+ self.status_code = status_code
75
+ self.payload = payload
76
+ self.upstream_status_code = upstream_status_code
77
+ # Source ``Retry-After`` for honest passthrough of 429/5xx (I8).
78
+ self.retry_after = retry_after
79
+ # Which bounded phase expired for ``model_source_timeout``/``model_source_idle_timeout``.
80
+ self.timeout_phase = timeout_phase
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class SourceUsage:
85
+ input_tokens: int
86
+ output_tokens: int
87
+ cached_input_tokens: int = 0
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class SourceTimings:
92
+ """Server-reported generation timing, for TTFT/tokens-per-second reporting.
93
+
94
+ Maps directly onto ``RequestLog.latency_first_token_ms`` /
95
+ ``RequestLog.latency_ms`` so source-routed requests get the same TTFT and
96
+ tokens-per-second dashboard reporting as subscription-backed ones, sourced
97
+ from the upstream's own measurements rather than proxy-side timers (the
98
+ proxy does not instrument source forwarding round trips itself).
99
+ """
100
+
101
+ latency_first_token_ms: int
102
+ latency_ms: int
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class SourceChatCompletion:
107
+ payload: dict[str, JsonValue]
108
+ usage: SourceUsage | None
109
+ timings: SourceTimings | None
110
+ upstream_status_code: int
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class SourceResponsesCompletion:
115
+ payload: dict[str, JsonValue]
116
+ usage: SourceUsage | None
117
+ timings: SourceTimings | None
118
+ upstream_status_code: int
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class SourceAudioTranscription:
123
+ body: bytes
124
+ content_type: str | None
125
+ usage: SourceUsage | None
126
+ audio_seconds: float | None
127
+ timings: SourceTimings | None
128
+ upstream_status_code: int
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class SourceEmbeddings:
133
+ payload: dict[str, JsonValue]
134
+ usage: SourceUsage | None
135
+ upstream_status_code: int
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class _TransportOwnedStream:
140
+ """A source stream whose ``body`` and direct ``aclose()`` converge on one transport release.
141
+
142
+ ``transport`` owns the session lease and the upstream response (``None``
143
+ for the synthetic streams tests build) and is shared with ``body`` so both
144
+ close paths meet at its single latch. ``aclose()`` on a never-started
145
+ async generator skips its ``finally`` block, so a stream that is torn down
146
+ before iteration begins -- a client that leaves between the route
147
+ returning and Starlette's first body write -- would otherwise keep the
148
+ pooled lease and the upstream connection until garbage collection.
149
+ """
150
+
151
+ transport: "SourceStreamTransport | None" = field(default=None, kw_only=True, repr=False, compare=False)
152
+
153
+ async def aclose(self) -> None:
154
+ """Release the source connection directly (idempotent), even if ``body`` was never started."""
155
+
156
+ if self.transport is not None:
157
+ await self.transport.aclose()
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class SourceChatStream(_TransportOwnedStream):
162
+ body: AsyncIterator[bytes]
163
+ usage_holder: "SourceUsageHolder"
164
+ upstream_status_code: int
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class SourceResponsesStream(_TransportOwnedStream):
169
+ body: AsyncIterator[bytes]
170
+ usage_holder: "SourceUsageHolder"
171
+ upstream_status_code: int
172
+
173
+
174
+ @dataclass(slots=True)
175
+ class SourceUsageHolder:
176
+ usage: SourceUsage | None = None
177
+ timings: SourceTimings | None = None
178
+ # Responses-stream observations for the dispatch owner (#2123 WP-C1, design §6).
179
+ response_id: str | None = None
180
+ created_envelope: dict[str, JsonValue] | None = None
181
+ first_frame_at: float | None = None
182
+ # A frame that delivers response content to the client -- a ``content``
183
+ # frame or a success terminal -- has been parsed (I11: delivered => pinned).
184
+ first_content_seen: bool = False
185
+ first_output_item_seen: bool = False
186
+ # Set at the flush point of ``_source_stream_body``: a chunk carrying
187
+ # content (``first_content_seen``) has been handed to the consumer, after
188
+ # the pre-content hook and never before it. This is the body's evidence for
189
+ # the hook ordering (I11); it is not the billing signal. The consumer is the
190
+ # public wrapper chain, which may still drop, park or hold the frame, so
191
+ # ``SourceDispatch.content_delivered`` is derived by the settlement layer
192
+ # from the frames it hands to the transport and never mirrored from here.
193
+ content_delivered: bool = False
194
+ terminal_kind: Literal["completed", "incomplete", "failed", "error"] | None = None
195
+ delta_chars: int = 0
196
+
197
+
198
+ # Awaited once, before the first content frame is released to the client
199
+ # (the pin hook); a failure terminal with no prior content never invokes it.
200
+ OnFirstContent = Callable[[SourceUsageHolder], Awaitable[None]]
201
+
202
+ _NON_CONTENT_EVENT_TYPES = frozenset({"response.created", "response.in_progress", "response.queued"})
203
+ _SUCCESS_TERMINAL_EVENT_TYPES = frozenset({"response.completed", "response.incomplete"})
204
+ _FAILURE_TERMINAL_EVENT_TYPES = frozenset({"response.failed", "error"})
205
+ _FAILURE_TERMINAL_KINDS = frozenset({"failed", "error"})
206
+ _SOURCE_STREAM_CHUNK_BYTES = 4096
207
+
208
+
209
+ def source_stream_idle_seconds() -> float:
210
+ """``min(settings.stream_idle_timeout_seconds, SOURCE_STREAM_IDLE_CAP_SECONDS)``.
211
+
212
+ The subscription idle window (7200 s by default) is sized for ChatGPT
213
+ backends the proxy trusts; an operator-configured source gets at most the
214
+ cap so a silent source never holds a client stream open for hours.
215
+ """
216
+
217
+ return min(
218
+ float(with_dashboard_overrides(get_settings()).stream_idle_timeout_seconds), SOURCE_STREAM_IDLE_CAP_SECONDS
219
+ )
220
+
221
+
222
+ def classify_responses_frame(event_type: str | None) -> FrameKind:
223
+ """Classify a Responses SSE frame by its event ``type`` for the pre-content hook.
224
+
225
+ ``response.created`` / ``response.in_progress`` / ``response.queued`` are
226
+ bookkeeping the client does not need before content and are withheld while
227
+ a hook is armed; ``None`` (a frame without a typed event: SSE comments and
228
+ keepalives, the ``[DONE]`` sentinel, unparseable ``data``) is treated the
229
+ same way. ``response.completed`` / ``response.incomplete`` are success
230
+ terminals, ``response.failed`` / ``error`` failure terminals. Every other
231
+ type -- ``response.output_item.added``, the ``*.delta`` family, and any
232
+ event this proxy does not know -- is ``content``: unknown frames are never
233
+ withheld past the hook, so a newer event vocabulary cannot stall a stream.
234
+ Callers read the type with the public wrapper's own ``classify_event_type``
235
+ so a typeless ``{"error": {...}}`` record is the ``error`` terminal on both
236
+ sides of the pipeline.
237
+ """
238
+
239
+ if event_type is None or event_type in _NON_CONTENT_EVENT_TYPES:
240
+ return "non_content"
241
+ if event_type in _SUCCESS_TERMINAL_EVENT_TYPES:
242
+ return "success_terminal"
243
+ if event_type in _FAILURE_TERMINAL_EVENT_TYPES:
244
+ return "failure_terminal"
245
+ return "content"
246
+
247
+
248
+ async def _await_cleanup_deferring_cancellation(
249
+ awaitable: Awaitable[object],
250
+ *,
251
+ scheduler: Scheduler = REAL_SCHEDULER,
252
+ ) -> None:
253
+ """Finish owned upstream cleanup even if the caller is cancelled again."""
254
+
255
+ await _shared_await_cleanup_deferring_cancellation(awaitable, scheduler=scheduler)
256
+
257
+
258
+ async def _await_result_deferring_cancellation(
259
+ awaitable: Awaitable[object],
260
+ *,
261
+ scheduler: Scheduler = REAL_SCHEDULER,
262
+ ) -> bool:
263
+ """Finish owned cleanup and report whether cancellation arrived mid-flight."""
264
+
265
+ return await _shared_await_cleanup_deferring_cancellation(awaitable, scheduler=scheduler) is not None
266
+
267
+
268
+ class SourceStreamTransport:
269
+ """Single close latch for the exit stack that owns a source stream (session lease + response).
270
+
271
+ ``AsyncExitStack.aclose`` runs each callback exactly once, but two callers
272
+ closing concurrently (the body's ``finally`` and a direct
273
+ ``SourceResponsesStream.aclose``) would split the callbacks between them
274
+ and release the lease before the response. The close therefore runs in one
275
+ owned task that every caller awaits deferring its own cancellation, so a
276
+ Starlette scope that keeps re-cancelling the response task still returns
277
+ the pooled lease.
278
+ """
279
+
280
+ __slots__ = ("_close_task", "_scheduler", "_stack")
281
+
282
+ def __init__(self, stack: AsyncExitStack, *, scheduler: Scheduler) -> None:
283
+ self._stack = stack
284
+ self._scheduler = scheduler
285
+ self._close_task: asyncio.Task[None] | None = None
286
+
287
+ async def aclose(self) -> None:
288
+ if self._close_task is None:
289
+ self._close_task = self._scheduler.create_task(self._stack.aclose())
290
+ await _await_task_deferring_cancellation(self._close_task)
291
+
292
+
293
+ async def forward_chat_completion(
294
+ source: ModelSource,
295
+ payload: dict[str, JsonValue],
296
+ *,
297
+ encryptor: TokenEncryptor | None = None,
298
+ ) -> SourceChatCompletion:
299
+ stack = AsyncExitStack()
300
+ try:
301
+ session = await stack.enter_async_context(lease_model_source_session())
302
+ response = await stack.enter_async_context(
303
+ session.post(
304
+ _source_url(source, "/chat/completions"),
305
+ headers=_source_headers(source, encryptor=encryptor),
306
+ json=payload,
307
+ timeout=_source_client_timeout(source),
308
+ )
309
+ )
310
+ data = await _response_json(response)
311
+ if response.status >= 400:
312
+ raise _upstream_status_error(response, source, encryptor=encryptor, error_payload=_error_payload(data))
313
+ if data is None:
314
+ raise _invalid_upstream_response_error(response.status)
315
+ result = SourceChatCompletion(
316
+ payload=data,
317
+ usage=_usage_from_chat_payload(data),
318
+ timings=_timings_from_payload(data),
319
+ upstream_status_code=response.status,
320
+ )
321
+ except (aiohttp.ClientError, TimeoutError) as exc:
322
+ await _await_cleanup_deferring_cancellation(stack.aclose())
323
+ raise _unreachable_error(exc) from exc
324
+ except BaseException:
325
+ await _await_cleanup_deferring_cancellation(stack.aclose())
326
+ raise
327
+
328
+ cleanup_cancelled = await _await_result_deferring_cancellation(stack.aclose())
329
+ if cleanup_cancelled:
330
+ raise asyncio.CancelledError
331
+ return result
332
+
333
+
334
+ async def stream_chat_completion(
335
+ source: ModelSource,
336
+ payload: dict[str, JsonValue],
337
+ *,
338
+ encryptor: TokenEncryptor | None = None,
339
+ scheduler: Scheduler = REAL_SCHEDULER,
340
+ clock: Clock = REAL_CLOCK,
341
+ ) -> SourceChatStream:
342
+ usage_holder = SourceUsageHolder()
343
+ usage_parser = SourceStreamUsageParser(usage_holder, response_shape="chat")
344
+ # Chat completions keep the source's own 401/403 envelope (recode is a
345
+ # Responses-dispatch decision) and return at the source's headers exactly
346
+ # as before the hardening: the first byte is the first token, which a local
347
+ # source produces only after prompt processing, so the body reads it under
348
+ # the source's total budget alone while the client already holds the
349
+ # ``200`` -- a client that leaves during prompt processing cancels the body
350
+ # and releases the connection, the pooled lease and the reservation at
351
+ # once instead of holding all of them until the first token. The connect
352
+ # bound, the dedicated connector and the mid-stream idle cap apply.
353
+ stack, response, first_chunk = await _open_source_stream(
354
+ source,
355
+ "/chat/completions",
356
+ payload,
357
+ encryptor=encryptor,
358
+ scheduler=scheduler,
359
+ clock=clock,
360
+ header_deadline_seconds=None,
361
+ first_frame_deadline_seconds=None,
362
+ )
363
+ transport = SourceStreamTransport(stack, scheduler=scheduler)
364
+ body = _source_stream_body(
365
+ response,
366
+ first_chunk,
367
+ usage_parser,
368
+ usage_holder,
369
+ transport,
370
+ on_first_content=None,
371
+ idle_seconds=source_stream_idle_seconds(),
372
+ scheduler=scheduler,
373
+ clock=clock,
374
+ )
375
+ return SourceChatStream(
376
+ body=body,
377
+ usage_holder=usage_holder,
378
+ upstream_status_code=response.status,
379
+ transport=transport,
380
+ )
381
+
382
+
383
+ async def forward_responses(
384
+ source: ModelSource,
385
+ payload: dict[str, JsonValue],
386
+ *,
387
+ encryptor: TokenEncryptor | None = None,
388
+ recode_credential_failures: bool = True,
389
+ ) -> SourceResponsesCompletion:
390
+ try:
391
+ async with lease_model_source_session() as session:
392
+ # Non-stream generations legitimately spend minutes before the
393
+ # first byte, so only connect establishment and the source's total
394
+ # budget are bounded here (no header/first-frame deadline).
395
+ async with session.post(
396
+ _source_url(source, "/responses"),
397
+ headers=_source_headers(source, encryptor=encryptor),
398
+ json=payload,
399
+ timeout=_source_client_timeout(source),
400
+ ) as response:
401
+ if response.status >= 400:
402
+ if recode_credential_failures and response.status in _CREDENTIAL_REJECTION_STATUSES:
403
+ raise _credentials_rejected_error(response, source)
404
+ data = await _response_json(response)
405
+ raise _upstream_status_error(
406
+ response, source, encryptor=encryptor, error_payload=_error_payload(data)
407
+ )
408
+ data = await _response_json(response)
409
+ if data is None:
410
+ raise _invalid_upstream_response_error(response.status)
411
+ return SourceResponsesCompletion(
412
+ payload=data,
413
+ usage=_usage_from_responses_payload(data),
414
+ timings=_timings_from_payload(data),
415
+ upstream_status_code=response.status,
416
+ )
417
+ except (aiohttp.ClientError, TimeoutError) as exc:
418
+ raise _unreachable_error(exc) from exc
419
+
420
+
421
+ async def forward_audio_transcription(
422
+ source: ModelSource,
423
+ *,
424
+ audio_bytes: bytes,
425
+ filename: str,
426
+ content_type: str | None,
427
+ fields: list[tuple[str, str]],
428
+ encryptor: TokenEncryptor | None = None,
429
+ ) -> SourceAudioTranscription:
430
+ normalized_filename = filename.strip() if filename else "audio.wav"
431
+ normalized_content_type = content_type.strip() if content_type else "application/octet-stream"
432
+ form = aiohttp.FormData()
433
+ form.add_field(
434
+ "file",
435
+ audio_bytes,
436
+ filename=normalized_filename,
437
+ content_type=normalized_content_type,
438
+ )
439
+ for key, value in fields:
440
+ form.add_field(key, value)
441
+ try:
442
+ async with lease_model_source_session() as session:
443
+ async with session.post(
444
+ _source_url(source, "/audio/transcriptions"),
445
+ headers=_source_headers(source, encryptor=encryptor, accept="*/*", content_type=None),
446
+ data=form,
447
+ timeout=_source_client_timeout(source),
448
+ ) as response:
449
+ body = await response.read()
450
+ response_content_type = response.headers.get("Content-Type")
451
+ if response.status >= 400:
452
+ raise _upstream_status_error(
453
+ response,
454
+ source,
455
+ encryptor=encryptor,
456
+ error_payload=_error_payload_from_body(body, response_content_type),
457
+ )
458
+ return SourceAudioTranscription(
459
+ body=body,
460
+ content_type=response_content_type,
461
+ usage=_usage_from_audio_body(body, response_content_type),
462
+ audio_seconds=_audio_seconds_from_body(body, response_content_type),
463
+ timings=_timings_from_audio_body(body, response_content_type),
464
+ upstream_status_code=response.status,
465
+ )
466
+ except (aiohttp.ClientError, TimeoutError) as exc:
467
+ raise _unreachable_error(exc) from exc
468
+
469
+
470
+ async def forward_embeddings(
471
+ source: ModelSource,
472
+ payload: dict[str, JsonValue],
473
+ *,
474
+ encryptor: TokenEncryptor | None = None,
475
+ ) -> SourceEmbeddings:
476
+ try:
477
+ async with lease_model_source_session() as session:
478
+ async with session.post(
479
+ _source_url(source, "/embeddings"),
480
+ headers=_source_headers(source, encryptor=encryptor),
481
+ json=payload,
482
+ timeout=_source_client_timeout(source),
483
+ ) as response:
484
+ data = await _response_json(response)
485
+ if response.status >= 400:
486
+ raise _upstream_status_error(
487
+ response, source, encryptor=encryptor, error_payload=_error_payload(data)
488
+ )
489
+ if data is None:
490
+ raise _invalid_upstream_response_error(response.status)
491
+ return SourceEmbeddings(
492
+ payload=data,
493
+ usage=_usage_from_embeddings_payload(data),
494
+ upstream_status_code=response.status,
495
+ )
496
+ except (aiohttp.ClientError, TimeoutError) as exc:
497
+ raise _unreachable_error(exc) from exc
498
+
499
+
500
+ async def stream_responses(
501
+ source: ModelSource,
502
+ payload: dict[str, JsonValue],
503
+ *,
504
+ encryptor: TokenEncryptor | None = None,
505
+ on_first_content: OnFirstContent | None = None,
506
+ recode_credential_failures: bool = True,
507
+ scheduler: Scheduler = REAL_SCHEDULER,
508
+ clock: Clock = REAL_CLOCK,
509
+ ) -> SourceResponsesStream:
510
+ usage_holder = SourceUsageHolder()
511
+ usage_parser = SourceStreamUsageParser(usage_holder, response_shape="responses")
512
+ stack, response, first_chunk = await _open_source_stream(
513
+ source,
514
+ "/responses",
515
+ payload,
516
+ encryptor=encryptor,
517
+ recode_credential_failures=recode_credential_failures,
518
+ scheduler=scheduler,
519
+ clock=clock,
520
+ )
521
+ # The open returns with the first frame in hand, so this is the instant the
522
+ # source proved it is producing; the body yields that frame first.
523
+ usage_holder.first_frame_at = clock.monotonic()
524
+ transport = SourceStreamTransport(stack, scheduler=scheduler)
525
+ body = _source_stream_body(
526
+ response,
527
+ first_chunk,
528
+ usage_parser,
529
+ usage_holder,
530
+ transport,
531
+ on_first_content=on_first_content,
532
+ idle_seconds=source_stream_idle_seconds(),
533
+ scheduler=scheduler,
534
+ clock=clock,
535
+ )
536
+ return SourceResponsesStream(
537
+ body=body,
538
+ usage_holder=usage_holder,
539
+ upstream_status_code=response.status,
540
+ transport=transport,
541
+ )
542
+
543
+
544
+ class _SourceBudgetExpired(Exception):
545
+ """aiohttp's own total-budget timer fired inside a chunk read (not the idle cap)."""
546
+
547
+ def __init__(self, original: TimeoutError) -> None:
548
+ super().__init__(str(original))
549
+ self.original = original
550
+
551
+
552
+ async def _next_source_chunk(chunks: AsyncIterator[bytes]) -> bytes | None:
553
+ """One chunk, ``None`` at EOF; aiohttp's total-budget ``TimeoutError`` is tagged so the idle timer is not blamed."""
554
+
555
+ try:
556
+ return await anext(chunks)
557
+ except StopAsyncIteration:
558
+ return None
559
+ except TimeoutError as exc:
560
+ raise _SourceBudgetExpired(exc) from exc
561
+
562
+
563
+ async def _first_source_chunk(chunks: AsyncIterator[bytes]) -> bytes | None:
564
+ """The first chunk of a stream whose open returned at the headers, read under the source's total budget alone.
565
+
566
+ A budget or transport failure is the ``502 model_source_unreachable``
567
+ verdict the open gives the first-frame phase; ``None`` when the ``2xx``
568
+ stream ends before its first chunk -- the client already holds the
569
+ ``200`` and headers, so the caller ends the body instead of raising into
570
+ it (decision 50). No idle timer: the pre-first-token silence of a
571
+ chat-completions source is prompt processing.
572
+ """
573
+
574
+ try:
575
+ return await anext(chunks)
576
+ except StopAsyncIteration:
577
+ return None
578
+ except (aiohttp.ClientError, TimeoutError) as exc:
579
+ raise _unreachable_error(exc) from exc
580
+
581
+
582
+ async def _source_stream_body(
583
+ response: aiohttp.ClientResponse,
584
+ first_chunk: bytes | None,
585
+ usage_parser: SourceStreamUsageParser,
586
+ usage_holder: SourceUsageHolder,
587
+ transport: SourceStreamTransport,
588
+ *,
589
+ on_first_content: OnFirstContent | None,
590
+ idle_seconds: float,
591
+ scheduler: Scheduler,
592
+ clock: Clock,
593
+ ) -> AsyncIterator[bytes]:
594
+ """Relay source bytes chunk by chunk; the transport is released exactly once.
595
+
596
+ ``first_chunk`` is the chunk the open already read (Responses streams,
597
+ yielded before reading further) or ``None`` when the open returned at the
598
+ headers (chat-completions streams): then the first chunk is read here,
599
+ under the source's total budget alone and without the idle timer, and
600
+ ``usage_holder.first_frame_at`` is stamped when it arrives. A ``2xx``
601
+ source that closes before that first chunk ends the body cleanly -- the
602
+ client already holds the ``200`` and headers, so the empty stream ``main``
603
+ relayed is what it receives, never an exception out of a started body --
604
+ and ``first_frame_at`` stays ``None`` as the stream owner's evidence for
605
+ the ``invalid_upstream_response`` verdict (decision 50). Every chunk
606
+ read after the first frame is bounded by ``idle_seconds``
607
+ (``source_stream_idle_seconds()``) through the scheduler seam rather than
608
+ aiohttp's ``sock_read``: the socket timer is armed from request send and a
609
+ low configured idle window would pre-empt the header and first-frame
610
+ deadlines, whereas this timer starts only once the source has proved it is
611
+ producing. Expiry raises ``model_source_idle_timeout``; the source's own
612
+ total budget (aiohttp ``total``) keeps propagating as today.
613
+
614
+ Without a hook every chunk is yielded as soon as it is parsed (the direct
615
+ routing path adds no buffering). With ``on_first_content`` armed, chunks
616
+ are withheld until the parser sees the first frame that delivers content
617
+ (a ``content`` frame or a success terminal); the hook is awaited once and
618
+ the withheld bytes are flushed in order behind it. A failure terminal with
619
+ no prior content flushes without the hook. At EOF the parser's unterminated
620
+ tail is parsed as a final frame first (a record the source closed with a
621
+ single newline is still a frame the client will parse), and only a tail
622
+ that delivers nothing flushes without the hook: nothing was delivered, so
623
+ there is nothing to pin. A frame that outgrows the parser's buffer cap
624
+ counts as content (it cannot be a bookkeeping envelope and its truncated
625
+ remainder never parses), so an oversized terminal never reaches the client
626
+ unpinned. The withheld bytes are bounded by
627
+ ``SOURCE_STREAM_WITHHELD_CAP_BYTES``: a source that keeps producing
628
+ complete bookkeeping frames past that bound fails closed with
629
+ ``502 invalid_upstream_response`` instead of growing the buffer for its
630
+ whole total budget. A hook exception propagates and the withheld bytes are
631
+ dropped -- the client must not receive content whose continuity was not
632
+ secured. ``usage_holder.content_delivered`` is set exactly where a chunk
633
+ carrying content is handed to the consumer (after the hook, never before)
634
+ as the body's hook-ordering evidence; the consumer is the public wrapper
635
+ chain, which can still drop or park that chunk, so the billing decision
636
+ (``SourceDispatch.content_delivered``) is made by the settlement layer from
637
+ the frames it hands to the transport, not from this flag.
638
+ """
639
+
640
+ withheld: list[bytes] | None = [] if on_first_content is not None else None
641
+ withheld_bytes = 0
642
+ chunks = response.content.iter_chunked(_SOURCE_STREAM_CHUNK_BYTES)
643
+ chunk: bytes | None = first_chunk
644
+ try:
645
+ if chunk is None:
646
+ # The open returned at the headers: a client that leaves during
647
+ # prompt processing cancels this wait, and the ``finally`` below
648
+ # releases the connection and the pooled lease at once.
649
+ chunk = await _first_source_chunk(chunks)
650
+ if chunk is None:
651
+ # EOF before the first chunk: a clean empty stream to the
652
+ # client that already holds the ``200`` (``main`` parity).
653
+ return
654
+ usage_holder.first_frame_at = clock.monotonic()
655
+ while True:
656
+ if chunk is None:
657
+ try:
658
+ chunk = await scheduler.wait_for(_next_source_chunk(chunks), idle_seconds)
659
+ except _SourceBudgetExpired as exc:
660
+ raise exc.original from exc.original.__cause__
661
+ except TimeoutError as exc:
662
+ raise _idle_timeout_error(idle_seconds) from exc
663
+ if chunk is None:
664
+ # EOF: an unterminated final record is still a frame.
665
+ usage_parser.finish()
666
+ break
667
+ usage_parser.feed(chunk)
668
+ if withheld is None:
669
+ if usage_holder.first_content_seen:
670
+ usage_holder.content_delivered = True
671
+ yield chunk
672
+ else:
673
+ withheld.append(chunk)
674
+ withheld_bytes += len(chunk)
675
+ if usage_holder.first_content_seen:
676
+ assert on_first_content is not None
677
+ await on_first_content(usage_holder)
678
+ usage_holder.content_delivered = True
679
+ released, withheld = withheld, None
680
+ for pending in released:
681
+ yield pending
682
+ elif usage_holder.terminal_kind in _FAILURE_TERMINAL_KINDS:
683
+ released, withheld = withheld, None
684
+ for pending in released:
685
+ yield pending
686
+ elif withheld_bytes > SOURCE_STREAM_WITHHELD_CAP_BYTES:
687
+ raise _withheld_cap_error(withheld_bytes)
688
+ chunk = None
689
+ if withheld:
690
+ if usage_holder.first_content_seen:
691
+ # The unterminated tail delivered content (I11: delivered => pinned).
692
+ assert on_first_content is not None
693
+ await on_first_content(usage_holder)
694
+ usage_holder.content_delivered = True
695
+ for pending in withheld:
696
+ yield pending
697
+ finally:
698
+ # A plain ``async with stack`` unwinds unshielded: repeated
699
+ # cancellation delivery can interrupt ``__aexit__`` mid-unwind and
700
+ # leak the pooled HTTP session lease.
701
+ await transport.aclose()
702
+
703
+
704
+ async def _open_source_stream(
705
+ source: ModelSource,
706
+ path: str,
707
+ payload: dict[str, JsonValue],
708
+ *,
709
+ encryptor: TokenEncryptor | None,
710
+ recode_credential_failures: bool = False,
711
+ scheduler: Scheduler = REAL_SCHEDULER,
712
+ clock: Clock = REAL_CLOCK,
713
+ header_deadline_seconds: float | None = SOURCE_HEADER_DEADLINE_SECONDS,
714
+ first_frame_deadline_seconds: float | None = SOURCE_FIRST_FRAME_DEADLINE_SECONDS,
715
+ ) -> tuple[AsyncExitStack, aiohttp.ClientResponse, bytes | None]:
716
+ """Open the upstream request eagerly so errors surface before headers.
717
+
718
+ Streaming callers wrap the returned response in a ``StreamingResponse``;
719
+ anything raised after that point arrives after the 200 status line has
720
+ been sent. Opening the request here lets upstream 4xx/5xx, connection
721
+ failures and a source that accepts the request but never produces a frame
722
+ map to a proper OpenAI error response instead of a truncated stream. The
723
+ open is bounded per phase (design v3 §8.2): connect establishment by
724
+ ``ClientTimeout``, the header wait by ``header_deadline_seconds`` and the
725
+ first body chunk by ``first_frame_deadline_seconds``, both through
726
+ ``scheduler.fail_after`` so virtual time can expire them. ``None`` leaves
727
+ the header wait to the source's total budget; ``None`` for the first-frame
728
+ deadline means the open does not read the first chunk at all and returns
729
+ at the headers, as it did before the hardening (chat-completions streams,
730
+ whose first byte is the first token): the body reads it under the total
731
+ budget while the client already holds the ``200``, so a client that leaves
732
+ during prompt processing frees the connection, the pooled lease and the
733
+ reservation instead of holding them until the first token. The mid-stream
734
+ idle cap is the body's (``_source_stream_body``). The returned exit stack
735
+ owns the session lease and response and must be closed by the stream body
736
+ (or the stream's ``aclose()``); the returned bytes are the first
737
+ chunk, which the body yields before reading further, or ``None`` when the
738
+ body reads it.
739
+ """
740
+ stack = AsyncExitStack()
741
+ opened_at = clock.monotonic()
742
+ try:
743
+ session = await stack.enter_async_context(lease_model_source_session())
744
+ try:
745
+ with _phase_deadline(scheduler, header_deadline_seconds):
746
+ response = await stack.enter_async_context(
747
+ session.post(
748
+ _source_url(source, path),
749
+ headers=_source_headers(source, encryptor=encryptor, stream=True),
750
+ json=payload,
751
+ timeout=_source_client_timeout(source),
752
+ )
753
+ )
754
+ except aiohttp.ConnectionTimeoutError as exc:
755
+ # ``connect`` / ``sock_connect`` expired: the source never accepted
756
+ # a connection, which is the existing "unreachable" verdict.
757
+ raise _unreachable_error(exc, timeout_phase="connect") from exc
758
+ except aiohttp.ClientError as exc:
759
+ raise _unreachable_error(exc) from exc
760
+ except TimeoutError as exc:
761
+ if header_deadline_seconds is None:
762
+ # Only the source's total budget was armed: the pre-hardening verdict.
763
+ raise _unreachable_error(exc) from exc
764
+ raise _timeout_error("header", source, elapsed=clock.monotonic() - opened_at) from exc
765
+ if response.status >= 400:
766
+ if recode_credential_failures and response.status in _CREDENTIAL_REJECTION_STATUSES:
767
+ raise _credentials_rejected_error(response, source)
768
+ data = await _read_error_body(response, scheduler=scheduler)
769
+ raise _upstream_status_error(response, source, encryptor=encryptor, error_payload=_error_payload(data))
770
+ if first_frame_deadline_seconds is None:
771
+ return stack, response, None
772
+ try:
773
+ with _phase_deadline(scheduler, first_frame_deadline_seconds):
774
+ first_chunk = await response.content.readany()
775
+ except TimeoutError as exc:
776
+ # anyio's deadline (or the source's total budget, a ``TimeoutError``
777
+ # too) -- either way the source accepted the request and produced
778
+ # nothing.
779
+ raise _timeout_error("first_frame", source, elapsed=clock.monotonic() - opened_at) from exc
780
+ except aiohttp.ClientError as exc:
781
+ raise _unreachable_error(exc) from exc
782
+ if not first_chunk:
783
+ raise empty_stream_error(response.status)
784
+ return stack, response, first_chunk
785
+ except BaseException:
786
+ await _await_cleanup_deferring_cancellation(stack.aclose(), scheduler=scheduler)
787
+ raise
788
+
789
+
790
+ _CREDENTIAL_REJECTION_STATUSES = frozenset({401, 403})
791
+
792
+
793
+ def _phase_deadline(scheduler: Scheduler, seconds: float | None) -> AbstractContextManager[Any]:
794
+ """``scheduler.fail_after(seconds)``, or no bound at all when the phase is left to the source's total budget."""
795
+
796
+ return scheduler.fail_after(seconds) if seconds is not None else nullcontext()
797
+
798
+
799
+ def _source_client_timeout(source: ModelSource) -> aiohttp.ClientTimeout:
800
+ """Per-request timeout: the source's total budget plus bounded TCP establishment.
801
+
802
+ Only ``sock_connect`` bounds the 10 s connection-establishment deadline, not
803
+ ``connect``: in aiohttp ``connect`` *also* bounds the wait for a free pooled
804
+ connection, so a source whose dedicated pool is saturated at
805
+ ``http_connector_limit_per_host`` would fail fast at 10 s with a misleading
806
+ ``model_source_unreachable`` verdict (and feed false breaker trips) instead
807
+ of queuing for a slot -- exactly the shape a whole exhausted pool funnelling
808
+ to one designated source produces, where ``max_concurrency`` unset promises
809
+ 'unlimited'. ``sock_connect`` bounds only a *new* connection's TCP handshake;
810
+ a genuine connect timeout still surfaces as ``ConnectionTimeoutError``
811
+ (``ClientSession._request`` wraps the ``sock_connect`` ``TimeoutError``), so
812
+ the ``connect`` phase verdict is unchanged. The pool-slot wait falls back to
813
+ the source's total budget (and, for a streaming Responses open, the header
814
+ deadline armed on the scheduler seam).
815
+
816
+ ``sock_read`` stays unset on purpose: aiohttp arms it from request send, so
817
+ it would shorten the header and first-frame phases under a low configured
818
+ idle window, and a non-stream generation that sends nothing until its
819
+ final JSON body must be bounded by the total budget alone. Stream idle is
820
+ enforced per chunk by ``_source_stream_body``.
821
+ """
822
+
823
+ return aiohttp.ClientTimeout(
824
+ total=_source_timeout_seconds(source),
825
+ sock_connect=SOURCE_CONNECT_DEADLINE_SECONDS,
826
+ )
827
+
828
+
829
+ async def _read_error_body(response: aiohttp.ClientResponse, *, scheduler: Scheduler) -> dict[str, JsonValue] | None:
830
+ """Read an error body under the first-frame deadline; ``None`` keeps the honest status with a generic envelope."""
831
+
832
+ try:
833
+ with scheduler.fail_after(SOURCE_FIRST_FRAME_DEADLINE_SECONDS):
834
+ return await _response_json(response)
835
+ except TimeoutError:
836
+ return None
837
+
838
+
839
+ def _retry_after_header(response: aiohttp.ClientResponse) -> str | None:
840
+ value = response.headers.get("Retry-After")
841
+ if value is None:
842
+ return None
843
+ stripped = value.strip()
844
+ return stripped or None
845
+
846
+
847
+ def _upstream_status_error(
848
+ response: aiohttp.ClientResponse,
849
+ source: ModelSource,
850
+ *,
851
+ encryptor: TokenEncryptor | None,
852
+ error_payload: dict[str, JsonValue],
853
+ ) -> ModelSourceForwardingError:
854
+ """Honest passthrough of a source 4xx/5xx: status, redacted envelope and ``Retry-After``."""
855
+
856
+ return ModelSourceForwardingError(
857
+ status_code=response.status,
858
+ payload=_redact_source_error_payload(error_payload, source, encryptor=encryptor),
859
+ upstream_status_code=response.status,
860
+ retry_after=_retry_after_header(response),
861
+ )
862
+
863
+
864
+ def _credentials_rejected_error(response: aiohttp.ClientResponse, source: ModelSource) -> ModelSourceForwardingError:
865
+ """Source 401/403 as a generic 502: the source's message embeds a masked copy of the proxy's key.
866
+
867
+ The body is never read, parsed, or logged; the server-side record is the
868
+ status alone.
869
+ """
870
+
871
+ logger.warning(
872
+ "OpenAI-compatible model source %s rejected the proxy's credentials (HTTP %s)",
873
+ source.id,
874
+ response.status,
875
+ )
876
+ return ModelSourceForwardingError(
877
+ status_code=502,
878
+ payload={
879
+ "error": {
880
+ "message": "OpenAI-compatible model source rejected the proxy's credentials",
881
+ "type": "upstream_error",
882
+ "code": "model_source_credentials_error",
883
+ }
884
+ },
885
+ upstream_status_code=response.status,
886
+ retry_after=_retry_after_header(response),
887
+ )
888
+
889
+
890
+ def _timeout_error(phase: TimeoutPhase, source: ModelSource, *, elapsed: float) -> ModelSourceForwardingError:
891
+ deadline = SOURCE_HEADER_DEADLINE_SECONDS if phase == "header" else SOURCE_FIRST_FRAME_DEADLINE_SECONDS
892
+ logger.warning(
893
+ "OpenAI-compatible model source %s exceeded the %s deadline (%.0fs) after %.1fs",
894
+ source.id,
895
+ phase,
896
+ deadline,
897
+ elapsed,
898
+ )
899
+ detail = "response headers" if phase == "header" else "the first response frame"
900
+ return ModelSourceForwardingError(
901
+ status_code=504,
902
+ payload={
903
+ "error": {
904
+ "message": f"OpenAI-compatible model source did not send {detail} within {deadline:.0f}s",
905
+ "type": "upstream_error",
906
+ "code": "model_source_timeout",
907
+ }
908
+ },
909
+ upstream_status_code=None,
910
+ timeout_phase=phase,
911
+ )
912
+
913
+
914
+ def _idle_timeout_error(idle_seconds: float) -> ModelSourceForwardingError:
915
+ return ModelSourceForwardingError(
916
+ status_code=504,
917
+ payload={
918
+ "error": {
919
+ "message": f"OpenAI-compatible model source stream went idle for {idle_seconds:.0f}s",
920
+ "type": "upstream_error",
921
+ "code": "model_source_idle_timeout",
922
+ }
923
+ },
924
+ upstream_status_code=None,
925
+ timeout_phase="idle",
926
+ )
927
+
928
+
929
+ def empty_stream_error(response_status: int) -> ModelSourceForwardingError:
930
+ """A ``2xx`` stream that ended before its first chunk: the open's verdict, and the stream owners' row verdict."""
931
+
932
+ return ModelSourceForwardingError(
933
+ status_code=502,
934
+ payload={
935
+ "error": {
936
+ "message": "OpenAI-compatible model source closed the stream before the first frame",
937
+ "type": "upstream_error",
938
+ "code": "invalid_upstream_response",
939
+ }
940
+ },
941
+ upstream_status_code=response_status,
942
+ )
943
+
944
+
945
+ def _withheld_cap_error(withheld_bytes: int) -> ModelSourceForwardingError:
946
+ """A hook is armed and the source produced only bookkeeping past the withheld-bytes cap (fail closed, I11)."""
947
+
948
+ return ModelSourceForwardingError(
949
+ status_code=502,
950
+ payload={
951
+ "error": {
952
+ "message": (
953
+ "OpenAI-compatible model source sent "
954
+ f"{withheld_bytes} bytes without a content frame (limit {SOURCE_STREAM_WITHHELD_CAP_BYTES})"
955
+ ),
956
+ "type": "upstream_error",
957
+ "code": "invalid_upstream_response",
958
+ }
959
+ },
960
+ upstream_status_code=None,
961
+ )
962
+
963
+
964
+ def _unreachable_error(exc: Exception, *, timeout_phase: TimeoutPhase | None = None) -> ModelSourceForwardingError:
965
+ return ModelSourceForwardingError(
966
+ status_code=502,
967
+ payload={
968
+ "error": {
969
+ "message": f"OpenAI-compatible model source request failed: {exc.__class__.__name__}",
970
+ "type": "upstream_error",
971
+ "code": "model_source_unreachable",
972
+ }
973
+ },
974
+ upstream_status_code=None,
975
+ timeout_phase=timeout_phase,
976
+ )
977
+
978
+
979
+ def _source_url(source: ModelSource, path: str) -> str:
980
+ return f"{source.base_url.rstrip('/')}{path}"
981
+
982
+
983
+ def _source_headers(
984
+ source: ModelSource,
985
+ *,
986
+ encryptor: TokenEncryptor | None,
987
+ stream: bool = False,
988
+ accept: str | None = None,
989
+ content_type: str | None = "application/json",
990
+ ) -> dict[str, str]:
991
+ headers = {
992
+ "Accept": accept or ("text/event-stream" if stream else "application/json"),
993
+ }
994
+ if content_type is not None:
995
+ headers["Content-Type"] = content_type
996
+ if source.api_key_encrypted is not None:
997
+ secret = _source_api_key_secret(source, encryptor=encryptor)
998
+ headers["Authorization"] = f"Bearer {secret}"
999
+ return headers
1000
+
1001
+
1002
+ def _source_api_key_secret(source: ModelSource, *, encryptor: TokenEncryptor | None) -> str:
1003
+ if source.api_key_encrypted is None:
1004
+ return ""
1005
+ active_encryptor = encryptor or TokenEncryptor()
1006
+ try:
1007
+ return active_encryptor.decrypt(source.api_key_encrypted)
1008
+ except Exception as exc:
1009
+ # A rotated encryption key file or corrupt DB value must surface as a
1010
+ # forwarding error (with reservation release at the routes), not a bare 500.
1011
+ raise ModelSourceForwardingError(
1012
+ status_code=502,
1013
+ payload={
1014
+ "error": {
1015
+ "message": "OpenAI-compatible model source credentials could not be decrypted",
1016
+ "type": "upstream_error",
1017
+ "code": "model_source_credentials_error",
1018
+ }
1019
+ },
1020
+ upstream_status_code=None,
1021
+ ) from exc
1022
+
1023
+
1024
+ def _redact_source_error_payload(
1025
+ payload: dict[str, JsonValue],
1026
+ source: ModelSource,
1027
+ *,
1028
+ encryptor: TokenEncryptor | None,
1029
+ ) -> dict[str, JsonValue]:
1030
+ if source.api_key_encrypted is None:
1031
+ return payload
1032
+ secret = _source_api_key_secret(source, encryptor=encryptor)
1033
+ if not secret:
1034
+ return payload
1035
+ redacted = _redact_json_value(payload, secret)
1036
+ return cast(dict[str, JsonValue], redacted) if isinstance(redacted, Mapping) else payload
1037
+
1038
+
1039
+ def _redact_json_value(value: JsonValue, secret: str) -> JsonValue:
1040
+ if isinstance(value, str):
1041
+ return value.replace(secret, "[REDACTED]")
1042
+ if isinstance(value, list):
1043
+ return [_redact_json_value(item, secret) for item in value]
1044
+ if isinstance(value, Mapping):
1045
+ mapping = cast(Mapping[str, JsonValue], value)
1046
+ return {key: _redact_json_value(item, secret) for key, item in mapping.items()}
1047
+ return value
1048
+
1049
+
1050
+ def _source_timeout_seconds(source: ModelSource) -> float:
1051
+ return float(source.timeout_seconds or _DEFAULT_SOURCE_TIMEOUT_SECONDS)
1052
+
1053
+
1054
+ async def _response_json(response: aiohttp.ClientResponse) -> dict[str, JsonValue] | None:
1055
+ """Parsed JSON object body, or ``None`` when the body is not valid JSON."""
1056
+ try:
1057
+ data = await response.json(content_type=None)
1058
+ except Exception:
1059
+ return None
1060
+ return data if isinstance(data, dict) else {"data": data}
1061
+
1062
+
1063
+ def _invalid_upstream_response_error(response_status: int) -> ModelSourceForwardingError:
1064
+ return ModelSourceForwardingError(
1065
+ status_code=502,
1066
+ payload={
1067
+ "error": {
1068
+ "message": "OpenAI-compatible model source returned a non-JSON response",
1069
+ "type": "upstream_error",
1070
+ "code": "invalid_upstream_response",
1071
+ }
1072
+ },
1073
+ upstream_status_code=response_status,
1074
+ )
1075
+
1076
+
1077
+ def _error_payload(data: Mapping[str, JsonValue] | None) -> dict[str, JsonValue]:
1078
+ error = data.get("error") if data is not None else None
1079
+ if is_json_mapping(error):
1080
+ return {"error": dict(error)}
1081
+ return {
1082
+ "error": {
1083
+ "message": "OpenAI-compatible model source returned an error",
1084
+ "type": "upstream_error",
1085
+ "code": "model_source_error",
1086
+ }
1087
+ }
1088
+
1089
+
1090
+ def _error_payload_from_body(body: bytes, content_type: str | None) -> dict[str, JsonValue]:
1091
+ if _is_json_content_type(content_type):
1092
+ try:
1093
+ parsed = json.loads(body.decode("utf-8"))
1094
+ except (UnicodeDecodeError, JSONDecodeError):
1095
+ parsed = None
1096
+ if isinstance(parsed, Mapping):
1097
+ return _error_payload(parsed)
1098
+ upstream_message = _text_error_message(body)
1099
+ return {
1100
+ "error": {
1101
+ "message": upstream_message or "OpenAI-compatible model source returned an error",
1102
+ "type": "upstream_error",
1103
+ "code": "model_source_error",
1104
+ }
1105
+ }
1106
+
1107
+
1108
+ def _text_error_message(body: bytes) -> str | None:
1109
+ try:
1110
+ text = body.decode("utf-8").strip()
1111
+ except UnicodeDecodeError:
1112
+ return None
1113
+ if not text:
1114
+ return None
1115
+ return text[:1000]
1116
+
1117
+
1118
+ def _usage_from_chat_payload(payload: Mapping[str, JsonValue]) -> SourceUsage | None:
1119
+ usage = payload.get("usage")
1120
+ if not is_json_mapping(usage):
1121
+ return None
1122
+ return _usage_from_mapping(usage)
1123
+
1124
+
1125
+ def _usage_from_responses_payload(payload: Mapping[str, JsonValue]) -> SourceUsage | None:
1126
+ usage = payload.get("usage")
1127
+ if not is_json_mapping(usage):
1128
+ return None
1129
+ return _usage_from_responses_mapping(usage)
1130
+
1131
+
1132
+ def _usage_from_embeddings_payload(payload: Mapping[str, JsonValue]) -> SourceUsage | None:
1133
+ """Embeddings responses report prompt/total tokens and no completion tokens."""
1134
+ usage = payload.get("usage")
1135
+ if not is_json_mapping(usage):
1136
+ return None
1137
+ return _usage_from_mapping(usage) or _usage_from_total_tokens_mapping(usage)
1138
+
1139
+
1140
+ def _usage_from_audio_body(body: bytes, content_type: str | None) -> SourceUsage | None:
1141
+ if not _is_json_content_type(content_type):
1142
+ return None
1143
+ try:
1144
+ parsed = json.loads(body.decode("utf-8"))
1145
+ except (UnicodeDecodeError, JSONDecodeError):
1146
+ return None
1147
+ if not is_json_mapping(parsed):
1148
+ return None
1149
+ usage = parsed.get("usage")
1150
+ if not is_json_mapping(usage):
1151
+ return None
1152
+ return _usage_from_mapping(usage) or _usage_from_responses_mapping(usage) or _usage_from_total_tokens_mapping(usage)
1153
+
1154
+
1155
+ def _audio_seconds_from_body(body: bytes, content_type: str | None) -> float | None:
1156
+ """Extract transcribed audio length in seconds from a JSON transcription body.
1157
+
1158
+ Recognizes the top-level ``duration`` field emitted by OpenAI
1159
+ ``verbose_json`` and most Whisper-compatible servers, and a
1160
+ ``usage.seconds`` / ``usage.duration`` fallback. Non-positive or
1161
+ non-numeric values yield ``None`` so duration billing fails closed.
1162
+ """
1163
+ if not _is_json_content_type(content_type):
1164
+ return None
1165
+ try:
1166
+ parsed = json.loads(body.decode("utf-8"))
1167
+ except (UnicodeDecodeError, JSONDecodeError):
1168
+ return None
1169
+ if not is_json_mapping(parsed):
1170
+ return None
1171
+ candidate = parsed.get("duration")
1172
+ if candidate is None:
1173
+ usage = parsed.get("usage")
1174
+ if is_json_mapping(usage):
1175
+ candidate = usage.get("seconds")
1176
+ if candidate is None:
1177
+ candidate = usage.get("duration")
1178
+ if isinstance(candidate, bool) or not isinstance(candidate, (int, float)):
1179
+ return None
1180
+ seconds = float(candidate)
1181
+ return seconds if seconds > 0 else None
1182
+
1183
+
1184
+ def _timings_from_payload(payload: Mapping[str, JsonValue]) -> SourceTimings | None:
1185
+ metrics = payload.get("metrics")
1186
+ if not is_json_mapping(metrics):
1187
+ return None
1188
+ return _timings_from_metrics(metrics)
1189
+
1190
+
1191
+ def _timings_from_audio_body(body: bytes, content_type: str | None) -> SourceTimings | None:
1192
+ if not _is_json_content_type(content_type):
1193
+ return None
1194
+ try:
1195
+ parsed = json.loads(body.decode("utf-8"))
1196
+ except (UnicodeDecodeError, JSONDecodeError):
1197
+ return None
1198
+ if not is_json_mapping(parsed):
1199
+ return None
1200
+ return _timings_from_payload(parsed)
1201
+
1202
+
1203
+ def _timings_from_metrics(metrics: Mapping[str, JsonValue]) -> SourceTimings | None:
1204
+ """Parse vLLM-style per-request timing metrics into ``SourceTimings``.
1205
+
1206
+ vLLM's OpenAI-compatible server (and compatible forks) can attach a
1207
+ ``metrics`` object alongside ``usage`` with ``time_to_first_token_ms``
1208
+ (TTFT) and ``generation_time_ms`` (wall-clock time to produce the
1209
+ completion tokens *after* the first token). Storing their sum as
1210
+ ``latency_ms`` alongside ``latency_first_token_ms`` lets the existing
1211
+ dashboard TPS calculation use ``generation_time_ms`` as its denominator,
1212
+ preserving the generation-only throughput semantics used for
1213
+ subscription-backed requests.
1214
+ """
1215
+ ttft = metrics.get("time_to_first_token_ms")
1216
+ generation = metrics.get("generation_time_ms")
1217
+ if isinstance(ttft, bool) or not isinstance(ttft, (int, float)):
1218
+ return None
1219
+ if isinstance(generation, bool) or not isinstance(generation, (int, float)):
1220
+ return None
1221
+ if (isinstance(ttft, float) and not isfinite(ttft)) or (isinstance(generation, float) and not isfinite(generation)):
1222
+ return None
1223
+ if ttft < 0 or generation < 0:
1224
+ return None
1225
+ return SourceTimings(
1226
+ latency_first_token_ms=round(ttft),
1227
+ latency_ms=round(ttft + generation),
1228
+ )
1229
+
1230
+
1231
+ def _usage_from_mapping(usage: Mapping[str, JsonValue]) -> SourceUsage | None:
1232
+ prompt_tokens = usage.get("prompt_tokens")
1233
+ completion_tokens = usage.get("completion_tokens")
1234
+ if not isinstance(prompt_tokens, int) or not isinstance(completion_tokens, int):
1235
+ return None
1236
+ if prompt_tokens < 0 or completion_tokens < 0:
1237
+ # Fail closed: negative counts from a misbehaving source would reduce
1238
+ # API-key limit counters or record negative cost at settlement.
1239
+ return None
1240
+ cached_tokens = 0
1241
+ details = usage.get("prompt_tokens_details")
1242
+ if is_json_mapping(details):
1243
+ raw_cached = details.get("cached_tokens")
1244
+ cached_tokens = raw_cached if isinstance(raw_cached, int) else 0
1245
+ return SourceUsage(
1246
+ input_tokens=prompt_tokens,
1247
+ output_tokens=completion_tokens,
1248
+ cached_input_tokens=max(0, min(cached_tokens, prompt_tokens)),
1249
+ )
1250
+
1251
+
1252
+ def _usage_from_responses_mapping(usage: Mapping[str, JsonValue]) -> SourceUsage | None:
1253
+ input_tokens = usage.get("input_tokens")
1254
+ output_tokens = usage.get("output_tokens")
1255
+ if not isinstance(input_tokens, int) or not isinstance(output_tokens, int):
1256
+ return None
1257
+ if input_tokens < 0 or output_tokens < 0:
1258
+ # Fail closed: negative counts from a misbehaving source would reduce
1259
+ # API-key limit counters or record negative cost at settlement.
1260
+ return None
1261
+ cached_tokens = 0
1262
+ details = usage.get("input_tokens_details")
1263
+ if is_json_mapping(details):
1264
+ raw_cached = details.get("cached_tokens")
1265
+ cached_tokens = raw_cached if isinstance(raw_cached, int) else 0
1266
+ return SourceUsage(
1267
+ input_tokens=input_tokens,
1268
+ output_tokens=output_tokens,
1269
+ cached_input_tokens=max(0, min(cached_tokens, input_tokens)),
1270
+ )
1271
+
1272
+
1273
+ def _usage_from_total_tokens_mapping(usage: Mapping[str, JsonValue]) -> SourceUsage | None:
1274
+ total_tokens = usage.get("total_tokens")
1275
+ if not isinstance(total_tokens, int) or total_tokens < 0:
1276
+ return None
1277
+ return SourceUsage(input_tokens=total_tokens, output_tokens=0)
1278
+
1279
+
1280
+ def _is_json_content_type(content_type: str | None) -> bool:
1281
+ if content_type is None:
1282
+ return False
1283
+ return content_type.split(";", 1)[0].strip().lower() in {"application/json", "text/json"}
1284
+
1285
+
1286
+ class SourceStreamUsageParser:
1287
+ # A single SSE frame carrying usage is tiny; anything past this cap means
1288
+ # the upstream is not producing frame boundaries we recognize, and the
1289
+ # parser must not buffer the whole stream in memory.
1290
+ _MAX_BUFFER_CHARS = 1_048_576
1291
+
1292
+ def __init__(self, usage_holder: SourceUsageHolder, *, response_shape: str) -> None:
1293
+ self._usage_holder = usage_holder
1294
+ self._response_shape = response_shape
1295
+ self._buffer = ""
1296
+ self._bom_pending = True
1297
+ self._cr_pending = False
1298
+
1299
+ def feed(self, chunk: bytes) -> None:
1300
+ # SSE permits CRLF (and bare CR) line endings; normalize so frame
1301
+ # detection below only has to handle "\n\n". A CRLF split across two
1302
+ # chunks must stay one line ending: the CR that closed the previous
1303
+ # chunk was already normalized to "\n", so a chunk that opens with its
1304
+ # LF drops that LF -- otherwise the pair became "\n\n" and cut one
1305
+ # frame into two halves that parse to nothing while the event-block
1306
+ # reassembler delivers the whole event to the client (I11: delivered
1307
+ # => pinned; usage never captured).
1308
+ text = chunk.decode("utf-8", errors="ignore")
1309
+ if self._cr_pending:
1310
+ self._cr_pending = False
1311
+ text = text.removeprefix("\n")
1312
+ self._cr_pending = text.endswith("\r")
1313
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
1314
+ if self._bom_pending and text:
1315
+ # One optional leading UTF-8 BOM, ignored exactly as the public
1316
+ # wrapper's event-block reassembler ignores it: left in place it
1317
+ # turns the first field name into "\ufeffdata", so the parser would
1318
+ # observe nothing from a record the wrapper delivers to the client
1319
+ # (I11: delivered => pinned; usage never captured).
1320
+ self._bom_pending = False
1321
+ text = text.removeprefix("\ufeff")
1322
+ self._buffer += text
1323
+ while "\n\n" in self._buffer:
1324
+ frame, self._buffer = self._buffer.split("\n\n", 1)
1325
+ self._capture_frame(frame)
1326
+ if len(self._buffer) > self._MAX_BUFFER_CHARS:
1327
+ # The frame under construction outgrew the cap: its truncated
1328
+ # remainder will never parse, so it can no longer be classified by
1329
+ # its event type. No bookkeeping envelope (``response.created`` /
1330
+ # ``in_progress`` / ``queued``) is this large, so it is treated as
1331
+ # content (I11: delivered => pinned) -- a pre-content hook must run
1332
+ # before any byte of it reaches the client, and the withheld buffer
1333
+ # stays bounded by the cap instead of the whole stream.
1334
+ if self._response_shape == "responses":
1335
+ self._usage_holder.first_content_seen = True
1336
+ self._buffer = self._buffer[-self._MAX_BUFFER_CHARS :]
1337
+
1338
+ def finish(self) -> None:
1339
+ """Parse the unterminated tail at EOF as a final frame.
1340
+
1341
+ SSE consumers (the proxy's own event-block reassembler included)
1342
+ accept a final record that the source closed without the trailing
1343
+ blank line, so its event type, usage and terminal kind must reach the
1344
+ holder like any other frame; a truncated oversized remainder never
1345
+ starts with ``data:`` and parses to nothing. Idempotent.
1346
+ """
1347
+
1348
+ tail, self._buffer = self._buffer, ""
1349
+ if tail.strip():
1350
+ self._capture_frame(tail)
1351
+
1352
+ def _capture_frame(self, frame: str) -> None:
1353
+ # Join multi-line ``data:`` fields through the same reconstruction the
1354
+ # public wrapper uses (``extract_sse_data`` -> ``parse_sse_data_json``),
1355
+ # so the parser and the wrapper never disagree on the event inside one
1356
+ # pipeline: a source that splits a data JSON across several ``data:``
1357
+ # lines (legal SSE) must still set the holder observations the branch's
1358
+ # billing and continuity decisions key on (I11), not parse only the
1359
+ # first line into a ``ValueError`` and observe nothing.
1360
+ data = extract_sse_data(frame)
1361
+ if data is None:
1362
+ return
1363
+ try:
1364
+ parsed = json.loads(data)
1365
+ except ValueError:
1366
+ return
1367
+ if not isinstance(parsed, dict):
1368
+ return
1369
+ if self._response_shape == "responses":
1370
+ usage = _usage_from_responses_event(parsed)
1371
+ timings = _timings_from_responses_event(parsed)
1372
+ self._observe_responses_event(parsed)
1373
+ else:
1374
+ usage = _usage_from_chat_payload(parsed)
1375
+ timings = _timings_from_payload(parsed)
1376
+ if usage is not None:
1377
+ self._usage_holder.usage = usage
1378
+ if timings is not None:
1379
+ self._usage_holder.timings = timings
1380
+
1381
+ def _observe_responses_event(self, event: dict[str, JsonValue]) -> None:
1382
+ """Record the frame observations the dispatch owner needs (design v3 §6).
1383
+
1384
+ One set membership on the event ``type`` plus a ``len()`` for delta
1385
+ frames on top of the ``json.loads`` the usage capture already paid;
1386
+ ``event`` is that ``json.loads`` result, owned by this parser and read
1387
+ only, so it is handed to the classifier without a copy.
1388
+ """
1389
+
1390
+ holder = self._usage_holder
1391
+ # The wrapper's classifier: a string ``type`` wins and a typeless record
1392
+ # carrying an ``error`` object is the ``error`` terminal (the wrapper
1393
+ # relays or rewrites it as a failure; the parser must not read it as
1394
+ # bookkeeping that is withheld ahead of the hook or settled as a cancel).
1395
+ event_type = classify_event_type(event)
1396
+ kind = classify_responses_frame(event_type)
1397
+ response = event.get("response")
1398
+ if is_json_mapping(response):
1399
+ response_id = response.get("id")
1400
+ if isinstance(response_id, str) and (holder.response_id is None or event_type == "response.created"):
1401
+ holder.response_id = response_id
1402
+ if event_type == "response.created" and holder.created_envelope is None:
1403
+ holder.created_envelope = dict(response)
1404
+ if kind == "non_content":
1405
+ return
1406
+ if kind == "failure_terminal":
1407
+ holder.terminal_kind = "error" if event_type == "error" else "failed"
1408
+ return
1409
+ holder.first_content_seen = True
1410
+ if kind == "success_terminal":
1411
+ holder.terminal_kind = "incomplete" if event_type == "response.incomplete" else "completed"
1412
+ return
1413
+ assert event_type is not None
1414
+ if event_type.startswith("response.output_item."):
1415
+ holder.first_output_item_seen = True
1416
+ elif event_type.endswith(".delta"):
1417
+ delta = event.get("delta")
1418
+ if isinstance(delta, str):
1419
+ holder.delta_chars += len(delta)
1420
+
1421
+
1422
+ def _usage_from_responses_event(payload: Mapping[str, JsonValue]) -> SourceUsage | None:
1423
+ response = payload.get("response")
1424
+ usage = _usage_from_responses_payload(response) if is_json_mapping(response) else None
1425
+ if usage is None:
1426
+ usage = _usage_from_responses_payload(payload)
1427
+ return usage
1428
+
1429
+
1430
+ def _timings_from_responses_event(payload: Mapping[str, JsonValue]) -> SourceTimings | None:
1431
+ response = payload.get("response")
1432
+ timings = _timings_from_payload(response) if is_json_mapping(response) else None
1433
+ if timings is None:
1434
+ timings = _timings_from_payload(payload)
1435
+ return timings