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