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,2123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from math import ceil
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from sqlalchemy.exc import IntegrityError, OperationalError
|
|
14
|
+
|
|
15
|
+
from app.core.auth.api_key_cache import get_api_key_cache
|
|
16
|
+
from app.core.cache.invalidation import NAMESPACE_API_KEY, get_cache_invalidation_poller
|
|
17
|
+
from app.core.clients.thread_cache_identity import (
|
|
18
|
+
THREAD_CACHE_IDENTITY_MODES,
|
|
19
|
+
normalize_thread_cache_identity_mode,
|
|
20
|
+
)
|
|
21
|
+
from app.core.usage.pricing import (
|
|
22
|
+
UsageTokens,
|
|
23
|
+
calculate_cost_from_usage,
|
|
24
|
+
get_pricing_for_model,
|
|
25
|
+
)
|
|
26
|
+
from app.core.usage.types import UsageWindowRow
|
|
27
|
+
from app.core.utils.time import to_utc_naive, utcnow
|
|
28
|
+
from app.db.models import Account, AccountStatus, ApiKey, ApiKeyLimit, LimitType, LimitWindow, ModelSource, UsageHistory
|
|
29
|
+
from app.db.session import sqlite_writer_section
|
|
30
|
+
from app.db.sqlite_lock_retry import should_retry_after_sqlite_lock
|
|
31
|
+
from app.modules.api_keys.last_used_coalescer import ApiKeyLastUsedCoalescer, get_api_key_last_used_coalescer
|
|
32
|
+
from app.modules.api_keys.limit_windows import advance_limit_reset, limit_window_delta, next_limit_reset
|
|
33
|
+
from app.modules.api_keys.repository import (
|
|
34
|
+
_UNSET,
|
|
35
|
+
ApiKeyTrendBucket,
|
|
36
|
+
ApiKeyUsageSummary,
|
|
37
|
+
ApiKeyUsageTotals,
|
|
38
|
+
ReservationResult,
|
|
39
|
+
UsageReservationData,
|
|
40
|
+
UsageReservationItemData,
|
|
41
|
+
_Unset,
|
|
42
|
+
)
|
|
43
|
+
from app.modules.usage.repository import UsageRepository
|
|
44
|
+
|
|
45
|
+
_SQLITE_BUSY_RETRY_ATTEMPTS = 4
|
|
46
|
+
_SQLITE_BUSY_RETRY_BASE_SECONDS = 0.1
|
|
47
|
+
_SPARKLINE_DAYS = 7
|
|
48
|
+
_DETAIL_BUCKET_SECONDS = 3600
|
|
49
|
+
API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET = 8_192
|
|
50
|
+
API_KEY_USAGE_RESERVATION_DEFAULT_INPUT_TOKENS = API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET
|
|
51
|
+
API_KEY_USAGE_RESERVATION_DEFAULT_OUTPUT_TOKENS = 2_048
|
|
52
|
+
_API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_MICRODOLLARS = 2_000_000
|
|
53
|
+
_API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_BASE_TOKENS = API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET * 2
|
|
54
|
+
TRAFFIC_CLASS_FOREGROUND = "foreground"
|
|
55
|
+
TRAFFIC_CLASS_OPPORTUNISTIC = "opportunistic"
|
|
56
|
+
_SUPPORTED_TRAFFIC_CLASSES = frozenset({TRAFFIC_CLASS_FOREGROUND, TRAFFIC_CLASS_OPPORTUNISTIC})
|
|
57
|
+
_SUPPORTED_TRANSPORT_POLICY_OVERRIDES = frozenset({"smart", "always_http", "always_websocket"})
|
|
58
|
+
_REASONING_POLICY_EXCLUSIVE_CONSTRAINT = "ck_api_keys_reasoning_policy_exclusive"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApiKeysRepositoryProtocol(Protocol):
|
|
62
|
+
async def create(self, row: ApiKey, *, commit: bool = True) -> ApiKey: ...
|
|
63
|
+
|
|
64
|
+
async def get_by_id(self, key_id: str) -> ApiKey | None: ...
|
|
65
|
+
|
|
66
|
+
async def get_for_limit_enforcement(self, key_id: str) -> ApiKey | None: ...
|
|
67
|
+
|
|
68
|
+
async def get_by_hash(self, key_hash: str) -> ApiKey | None: ...
|
|
69
|
+
|
|
70
|
+
async def list_all(self) -> list[ApiKey]: ...
|
|
71
|
+
async def list_usage_summary_by_key(self) -> dict[str, ApiKeyUsageSummary]: ...
|
|
72
|
+
async def get_usage_summary_by_key_id(self, key_id: str) -> ApiKeyUsageSummary: ...
|
|
73
|
+
async def get_limit_usage_value(
|
|
74
|
+
self,
|
|
75
|
+
key_id: str,
|
|
76
|
+
*,
|
|
77
|
+
limit_type: LimitType,
|
|
78
|
+
since: datetime,
|
|
79
|
+
until: datetime,
|
|
80
|
+
model_filter: str | None,
|
|
81
|
+
) -> int: ...
|
|
82
|
+
async def list_accounts_by_ids(self, account_ids: list[str]) -> list[Account]: ...
|
|
83
|
+
async def list_model_sources_by_ids(self, source_ids: list[str]) -> list[ModelSource]: ...
|
|
84
|
+
async def list_all_accounts(self) -> list[Account]: ...
|
|
85
|
+
|
|
86
|
+
async def update(
|
|
87
|
+
self,
|
|
88
|
+
key_id: str,
|
|
89
|
+
*,
|
|
90
|
+
name: str | _Unset = ...,
|
|
91
|
+
allowed_models: str | None | _Unset = ...,
|
|
92
|
+
apply_to_codex_model: bool | _Unset = ...,
|
|
93
|
+
enforced_model: str | None | _Unset = ...,
|
|
94
|
+
enforced_reasoning_effort: str | None | _Unset = ...,
|
|
95
|
+
allowed_reasoning_efforts: str | None | _Unset = ...,
|
|
96
|
+
enforced_service_tier: str | None | _Unset = ...,
|
|
97
|
+
traffic_class: str | _Unset = ...,
|
|
98
|
+
transport_policy_override: str | None | _Unset = ...,
|
|
99
|
+
thread_cache_identity_override: str | None | _Unset = ...,
|
|
100
|
+
usage_sections: str | _Unset = ...,
|
|
101
|
+
account_assignment_scope_enabled: bool | _Unset = ...,
|
|
102
|
+
source_assignment_scope_enabled: bool | _Unset = ...,
|
|
103
|
+
expires_at: datetime | None | _Unset = ...,
|
|
104
|
+
is_active: bool | _Unset = ...,
|
|
105
|
+
key_hash: str | _Unset = ...,
|
|
106
|
+
key_prefix: str | _Unset = ...,
|
|
107
|
+
commit: bool = True,
|
|
108
|
+
) -> ApiKey | None: ...
|
|
109
|
+
|
|
110
|
+
async def delete(self, key_id: str) -> bool: ...
|
|
111
|
+
|
|
112
|
+
async def commit(self) -> None: ...
|
|
113
|
+
|
|
114
|
+
async def rollback(self) -> None: ...
|
|
115
|
+
|
|
116
|
+
async def get_limits_by_key(self, key_id: str) -> list[ApiKeyLimit]: ...
|
|
117
|
+
|
|
118
|
+
async def replace_limits(self, key_id: str, limits: list[ApiKeyLimit]) -> list[ApiKeyLimit]: ...
|
|
119
|
+
|
|
120
|
+
async def upsert_limits(
|
|
121
|
+
self,
|
|
122
|
+
key_id: str,
|
|
123
|
+
limits: list[ApiKeyLimit],
|
|
124
|
+
*,
|
|
125
|
+
commit: bool = True,
|
|
126
|
+
preserve_matched_usage: bool = False,
|
|
127
|
+
) -> list[ApiKeyLimit]: ...
|
|
128
|
+
async def replace_account_assignments(
|
|
129
|
+
self, key_id: str, account_ids: list[str], *, commit: bool = True
|
|
130
|
+
) -> None: ...
|
|
131
|
+
async def replace_source_assignments(self, key_id: str, source_ids: list[str], *, commit: bool = True) -> None: ...
|
|
132
|
+
|
|
133
|
+
async def increment_limit_usage(
|
|
134
|
+
self,
|
|
135
|
+
key_id: str,
|
|
136
|
+
*,
|
|
137
|
+
model: str,
|
|
138
|
+
input_tokens: int,
|
|
139
|
+
output_tokens: int,
|
|
140
|
+
cost_microdollars: int,
|
|
141
|
+
) -> None: ...
|
|
142
|
+
|
|
143
|
+
async def reset_limit(self, limit_id: int, *, expected_reset_at: datetime, new_reset_at: datetime) -> bool: ...
|
|
144
|
+
|
|
145
|
+
async def try_reserve_usage(
|
|
146
|
+
self,
|
|
147
|
+
limit_id: int,
|
|
148
|
+
*,
|
|
149
|
+
delta: int,
|
|
150
|
+
expected_reset_at: datetime,
|
|
151
|
+
) -> ReservationResult: ...
|
|
152
|
+
|
|
153
|
+
async def adjust_reserved_usage(
|
|
154
|
+
self,
|
|
155
|
+
limit_id: int,
|
|
156
|
+
*,
|
|
157
|
+
delta: int,
|
|
158
|
+
expected_reset_at: datetime,
|
|
159
|
+
) -> bool: ...
|
|
160
|
+
|
|
161
|
+
async def create_usage_reservation(
|
|
162
|
+
self,
|
|
163
|
+
reservation_id: str,
|
|
164
|
+
*,
|
|
165
|
+
key_id: str,
|
|
166
|
+
model: str,
|
|
167
|
+
items: list[UsageReservationItemData],
|
|
168
|
+
) -> None: ...
|
|
169
|
+
|
|
170
|
+
async def get_usage_reservation(self, reservation_id: str) -> UsageReservationData | None: ...
|
|
171
|
+
|
|
172
|
+
async def transition_usage_reservation_status(
|
|
173
|
+
self,
|
|
174
|
+
reservation_id: str,
|
|
175
|
+
*,
|
|
176
|
+
expected_status: str,
|
|
177
|
+
new_status: str,
|
|
178
|
+
) -> bool: ...
|
|
179
|
+
|
|
180
|
+
async def upsert_reservation_item_actual(
|
|
181
|
+
self,
|
|
182
|
+
reservation_id: str,
|
|
183
|
+
*,
|
|
184
|
+
item: UsageReservationItemData,
|
|
185
|
+
actual_delta: int,
|
|
186
|
+
) -> None: ...
|
|
187
|
+
|
|
188
|
+
async def settle_usage_reservation(
|
|
189
|
+
self,
|
|
190
|
+
reservation_id: str,
|
|
191
|
+
*,
|
|
192
|
+
status: str,
|
|
193
|
+
input_tokens: int | None,
|
|
194
|
+
output_tokens: int | None,
|
|
195
|
+
cached_input_tokens: int | None,
|
|
196
|
+
cost_microdollars: int | None,
|
|
197
|
+
) -> None: ...
|
|
198
|
+
|
|
199
|
+
async def touch_usage_reservation(self, reservation_id: str) -> bool: ...
|
|
200
|
+
|
|
201
|
+
async def trends_by_key(
|
|
202
|
+
self,
|
|
203
|
+
key_id: str,
|
|
204
|
+
since: datetime,
|
|
205
|
+
until: datetime,
|
|
206
|
+
bucket_seconds: int = 3600,
|
|
207
|
+
) -> list[ApiKeyTrendBucket]: ...
|
|
208
|
+
|
|
209
|
+
async def usage_7d(
|
|
210
|
+
self,
|
|
211
|
+
key_id: str,
|
|
212
|
+
since: datetime,
|
|
213
|
+
until: datetime,
|
|
214
|
+
) -> ApiKeyUsageTotals: ...
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ApiKeyNotFoundError(ValueError):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ApiKeyInvalidError(ValueError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class ApiKeyValidationError(ValueError):
|
|
226
|
+
"""Raised when api_keys service input fails domain validation.
|
|
227
|
+
|
|
228
|
+
Subclasses ValueError so existing transitive callers that catch
|
|
229
|
+
``ValueError`` continue to work, but lets API routes catch the
|
|
230
|
+
typed exception explicitly so unrelated programming errors that
|
|
231
|
+
happen to surface as ``ValueError`` are no longer silently
|
|
232
|
+
converted into 4xx client responses (#619).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ApiKeyRateLimitExceededError(ValueError):
|
|
239
|
+
def __init__(self, *, message: str, reset_at: datetime) -> None:
|
|
240
|
+
super().__init__(message)
|
|
241
|
+
self.reset_at = reset_at
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ensure_optional_int_token_budget(field_name: str, value: int | None) -> None:
|
|
245
|
+
if value is None:
|
|
246
|
+
return
|
|
247
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
248
|
+
raise TypeError(f"{field_name} must be an int or None")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True, slots=True)
|
|
252
|
+
class ApiKeyRequestUsageBudget:
|
|
253
|
+
input_tokens: int | None = None
|
|
254
|
+
output_tokens: int | None = None
|
|
255
|
+
|
|
256
|
+
def __post_init__(self) -> None:
|
|
257
|
+
_ensure_optional_int_token_budget("input_tokens", self.input_tokens)
|
|
258
|
+
_ensure_optional_int_token_budget("output_tokens", self.output_tokens)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True, slots=True)
|
|
262
|
+
class LimitRuleData:
|
|
263
|
+
id: int
|
|
264
|
+
limit_type: str
|
|
265
|
+
limit_window: str
|
|
266
|
+
max_value: int
|
|
267
|
+
current_value: int
|
|
268
|
+
model_filter: str | None
|
|
269
|
+
reset_at: datetime
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True, slots=True)
|
|
273
|
+
class LimitRuleInput:
|
|
274
|
+
limit_type: str
|
|
275
|
+
limit_window: str
|
|
276
|
+
max_value: int
|
|
277
|
+
model_filter: str | None = None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class ApiKeyCreateData:
|
|
282
|
+
name: str
|
|
283
|
+
allowed_models: list[str] | None
|
|
284
|
+
apply_to_codex_model: bool = False
|
|
285
|
+
enforced_model: str | None = None
|
|
286
|
+
enforced_reasoning_effort: str | None = None
|
|
287
|
+
allowed_reasoning_efforts: list[str] | None = None
|
|
288
|
+
enforced_service_tier: str | None = None
|
|
289
|
+
traffic_class: str = TRAFFIC_CLASS_FOREGROUND
|
|
290
|
+
transport_policy_override: str | None = None
|
|
291
|
+
thread_cache_identity_override: str | None = None
|
|
292
|
+
usage_sections: str = "upstream_limits,account_pool_usage"
|
|
293
|
+
expires_at: datetime | None = None
|
|
294
|
+
assigned_account_ids: list[str] | None = None
|
|
295
|
+
assigned_source_ids: list[str] | None = None
|
|
296
|
+
limits: list[LimitRuleInput] = field(default_factory=list)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass(frozen=True, slots=True)
|
|
300
|
+
class ApiKeyUpdateData:
|
|
301
|
+
name: str | None = None
|
|
302
|
+
name_set: bool = False
|
|
303
|
+
allowed_models: list[str] | None = None
|
|
304
|
+
allowed_models_set: bool = False
|
|
305
|
+
apply_to_codex_model: bool | None = None
|
|
306
|
+
apply_to_codex_model_set: bool = False
|
|
307
|
+
enforced_model: str | None = None
|
|
308
|
+
enforced_model_set: bool = False
|
|
309
|
+
enforced_reasoning_effort: str | None = None
|
|
310
|
+
enforced_reasoning_effort_set: bool = False
|
|
311
|
+
allowed_reasoning_efforts: list[str] | None = None
|
|
312
|
+
allowed_reasoning_efforts_set: bool = False
|
|
313
|
+
enforced_service_tier: str | None = None
|
|
314
|
+
enforced_service_tier_set: bool = False
|
|
315
|
+
traffic_class: str | None = None
|
|
316
|
+
traffic_class_set: bool = False
|
|
317
|
+
transport_policy_override: str | None = None
|
|
318
|
+
transport_policy_override_set: bool = False
|
|
319
|
+
thread_cache_identity_override: str | None = None
|
|
320
|
+
thread_cache_identity_override_set: bool = False
|
|
321
|
+
usage_sections: str | None = None
|
|
322
|
+
usage_sections_set: bool = False
|
|
323
|
+
expires_at: datetime | None = None
|
|
324
|
+
expires_at_set: bool = False
|
|
325
|
+
is_active: bool | None = None
|
|
326
|
+
is_active_set: bool = False
|
|
327
|
+
assigned_account_ids: list[str] | None = None
|
|
328
|
+
assigned_account_ids_set: bool = False
|
|
329
|
+
assigned_source_ids: list[str] | None = None
|
|
330
|
+
assigned_source_ids_set: bool = False
|
|
331
|
+
limits: list[LimitRuleInput] | None = None
|
|
332
|
+
limits_set: bool = False
|
|
333
|
+
reset_usage: bool = False
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@dataclass(frozen=True, slots=True)
|
|
337
|
+
class ApiKeyData:
|
|
338
|
+
id: str
|
|
339
|
+
name: str
|
|
340
|
+
key_prefix: str
|
|
341
|
+
allowed_models: list[str] | None
|
|
342
|
+
enforced_model: str | None
|
|
343
|
+
enforced_reasoning_effort: str | None
|
|
344
|
+
enforced_service_tier: str | None
|
|
345
|
+
expires_at: datetime | None
|
|
346
|
+
is_active: bool
|
|
347
|
+
created_at: datetime
|
|
348
|
+
last_used_at: datetime | None
|
|
349
|
+
allowed_reasoning_efforts: list[str] | None = None
|
|
350
|
+
apply_to_codex_model: bool = False
|
|
351
|
+
traffic_class: str = TRAFFIC_CLASS_FOREGROUND
|
|
352
|
+
transport_policy_override: str | None = None
|
|
353
|
+
thread_cache_identity_override: str | None = None
|
|
354
|
+
usage_sections: str = "upstream_limits,account_pool_usage"
|
|
355
|
+
limits: list[LimitRuleData] = field(default_factory=list)
|
|
356
|
+
usage_summary: "ApiKeyUsageSummaryData | None" = None
|
|
357
|
+
account_assignment_scope_enabled: bool = False
|
|
358
|
+
source_assignment_scope_enabled: bool = False
|
|
359
|
+
assigned_account_ids: list[str] = field(default_factory=list)
|
|
360
|
+
assigned_source_ids: list[str] = field(default_factory=list)
|
|
361
|
+
pooled_credits: "PooledCreditData | None" = None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True, slots=True)
|
|
365
|
+
class ApiKeyCreatedData(ApiKeyData):
|
|
366
|
+
key: str = ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass(frozen=True, slots=True)
|
|
370
|
+
class ApiKeyUsageSummaryData:
|
|
371
|
+
request_count: int
|
|
372
|
+
total_tokens: int
|
|
373
|
+
cached_input_tokens: int
|
|
374
|
+
total_cost_usd: float
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@dataclass(frozen=True, slots=True)
|
|
378
|
+
class PooledCreditData:
|
|
379
|
+
remaining_percent_primary: float | None = None
|
|
380
|
+
remaining_percent_secondary: float | None = None
|
|
381
|
+
capacity_credits_primary: float = 0.0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _compute_pooled_credits(
|
|
385
|
+
*,
|
|
386
|
+
assigned_account_ids: list[str],
|
|
387
|
+
all_accounts: list[Account],
|
|
388
|
+
primary_usage: dict[str, UsageHistory],
|
|
389
|
+
secondary_usage: dict[str, UsageHistory],
|
|
390
|
+
account_assignment_scope_enabled: bool = False,
|
|
391
|
+
) -> PooledCreditData:
|
|
392
|
+
import app.core.usage as usage_core
|
|
393
|
+
from app.modules.usage.mappers import usage_history_to_window_row
|
|
394
|
+
|
|
395
|
+
if account_assignment_scope_enabled and not assigned_account_ids:
|
|
396
|
+
return PooledCreditData()
|
|
397
|
+
|
|
398
|
+
if assigned_account_ids:
|
|
399
|
+
requested_account_ids = set(assigned_account_ids)
|
|
400
|
+
else:
|
|
401
|
+
requested_account_ids = {a.id for a in all_accounts}
|
|
402
|
+
|
|
403
|
+
account_map = {
|
|
404
|
+
a.id: a
|
|
405
|
+
for a in all_accounts
|
|
406
|
+
if a.id in requested_account_ids and a.status not in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED)
|
|
407
|
+
}
|
|
408
|
+
account_ids = set(account_map)
|
|
409
|
+
|
|
410
|
+
primary_rows_raw = [
|
|
411
|
+
usage_history_to_window_row(entry) for entry in primary_usage.values() if entry.account_id in account_ids
|
|
412
|
+
]
|
|
413
|
+
secondary_rows_raw = [
|
|
414
|
+
usage_history_to_window_row(entry) for entry in secondary_usage.values() if entry.account_id in account_ids
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
primary_rows, secondary_rows = usage_core.normalize_weekly_only_rows(
|
|
418
|
+
primary_rows_raw,
|
|
419
|
+
secondary_rows_raw,
|
|
420
|
+
)
|
|
421
|
+
now_epoch = int(time.time())
|
|
422
|
+
# A live primary sample is one whose reset has not elapsed; when none
|
|
423
|
+
# exists (upstream stopped reporting the short window), the pooled
|
|
424
|
+
# primary bar must read absent rather than frozen or optimistic.
|
|
425
|
+
has_live_primary = any(row.reset_at is None or row.reset_at > now_epoch for row in primary_rows)
|
|
426
|
+
primary_rows = usage_core.expire_elapsed_window_rows(primary_rows, now_epoch=now_epoch)
|
|
427
|
+
# Secondary (weekly) rows pool raw: upstream still reports long windows,
|
|
428
|
+
# so an elapsed sample is transient staleness the next refresh rewrites —
|
|
429
|
+
# zeroing it would briefly report an exhausted weekly pool as 100% free.
|
|
430
|
+
primary_rows = _seed_missing_usage_rows(primary_rows, account_ids)
|
|
431
|
+
secondary_rows = _seed_missing_usage_rows(secondary_rows, account_ids)
|
|
432
|
+
|
|
433
|
+
primary_summary = usage_core.summarize_usage_window(primary_rows, account_map, "primary")
|
|
434
|
+
secondary_summary = usage_core.summarize_usage_window(secondary_rows, account_map, "secondary")
|
|
435
|
+
|
|
436
|
+
primary_remaining = usage_core.remaining_percent_from_used(primary_summary.used_percent)
|
|
437
|
+
secondary_remaining = usage_core.remaining_percent_from_used(secondary_summary.used_percent)
|
|
438
|
+
|
|
439
|
+
if primary_summary.capacity_credits == 0.0 or not has_live_primary:
|
|
440
|
+
primary_remaining = None
|
|
441
|
+
|
|
442
|
+
return PooledCreditData(
|
|
443
|
+
remaining_percent_primary=primary_remaining,
|
|
444
|
+
remaining_percent_secondary=secondary_remaining,
|
|
445
|
+
capacity_credits_primary=primary_summary.capacity_credits,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _seed_missing_usage_rows(
|
|
450
|
+
rows: list[UsageWindowRow],
|
|
451
|
+
account_ids: set[str],
|
|
452
|
+
) -> list[UsageWindowRow]:
|
|
453
|
+
present_account_ids = {row.account_id for row in rows}
|
|
454
|
+
missing_account_ids = account_ids - present_account_ids
|
|
455
|
+
if not missing_account_ids:
|
|
456
|
+
return rows
|
|
457
|
+
return rows + [
|
|
458
|
+
UsageWindowRow(account_id=account_id, used_percent=0.0) for account_id in sorted(missing_account_ids)
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@dataclass(frozen=True, slots=True)
|
|
463
|
+
class ApiKeyUsageReservationData:
|
|
464
|
+
reservation_id: str
|
|
465
|
+
key_id: str
|
|
466
|
+
model: str
|
|
467
|
+
has_applicable_limits: bool = True
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class ApiKeysService:
|
|
471
|
+
def __init__(
|
|
472
|
+
self,
|
|
473
|
+
repository: ApiKeysRepositoryProtocol,
|
|
474
|
+
usage_repository: UsageRepository | None = None,
|
|
475
|
+
*,
|
|
476
|
+
last_used_coalescer: ApiKeyLastUsedCoalescer | None = None,
|
|
477
|
+
) -> None:
|
|
478
|
+
self._repository = repository
|
|
479
|
+
self._usage_repository = usage_repository
|
|
480
|
+
self._last_used_coalescer = last_used_coalescer or get_api_key_last_used_coalescer()
|
|
481
|
+
|
|
482
|
+
async def create_key(self, payload: ApiKeyCreateData) -> ApiKeyCreatedData:
|
|
483
|
+
_validate_unique_limit_rule_identities(payload.limits)
|
|
484
|
+
now = utcnow()
|
|
485
|
+
expires_at = _normalize_expires_at(payload.expires_at)
|
|
486
|
+
plain_key = _generate_plain_key()
|
|
487
|
+
normalized_allowed_models = _normalize_allowed_models(payload.allowed_models)
|
|
488
|
+
assigned_account_ids = await self._resolve_assigned_account_ids(payload.assigned_account_ids)
|
|
489
|
+
assigned_source_ids = await self._resolve_assigned_source_ids(payload.assigned_source_ids)
|
|
490
|
+
enforced_model = _normalize_model_slug(payload.enforced_model)
|
|
491
|
+
enforced_reasoning_effort = _normalize_reasoning_effort(payload.enforced_reasoning_effort)
|
|
492
|
+
allowed_reasoning_efforts = _normalize_allowed_reasoning_efforts(payload.allowed_reasoning_efforts)
|
|
493
|
+
enforced_service_tier = _normalize_service_tier(payload.enforced_service_tier)
|
|
494
|
+
traffic_class = _normalize_traffic_class(payload.traffic_class)
|
|
495
|
+
transport_policy_override = _normalize_transport_policy_override(payload.transport_policy_override)
|
|
496
|
+
thread_cache_identity_override = _normalize_thread_cache_identity_override(
|
|
497
|
+
payload.thread_cache_identity_override
|
|
498
|
+
)
|
|
499
|
+
usage_sections = _normalize_usage_sections(payload.usage_sections)
|
|
500
|
+
_validate_model_enforcement(enforced_model=enforced_model, allowed_models=normalized_allowed_models)
|
|
501
|
+
_validate_reasoning_effort_policy(
|
|
502
|
+
enforced_reasoning_effort=enforced_reasoning_effort,
|
|
503
|
+
allowed_reasoning_efforts=allowed_reasoning_efforts,
|
|
504
|
+
)
|
|
505
|
+
row = ApiKey(
|
|
506
|
+
id=str(__import__("uuid").uuid4()),
|
|
507
|
+
name=_normalize_name(payload.name),
|
|
508
|
+
key_hash=_hash_key(plain_key),
|
|
509
|
+
key_prefix=plain_key[:15],
|
|
510
|
+
allowed_models=_serialize_allowed_models(normalized_allowed_models),
|
|
511
|
+
apply_to_codex_model=bool(payload.apply_to_codex_model),
|
|
512
|
+
enforced_model=enforced_model,
|
|
513
|
+
enforced_reasoning_effort=enforced_reasoning_effort,
|
|
514
|
+
allowed_reasoning_efforts=_serialize_allowed_reasoning_efforts(allowed_reasoning_efforts),
|
|
515
|
+
enforced_service_tier=enforced_service_tier,
|
|
516
|
+
account_assignment_scope_enabled=bool(assigned_account_ids),
|
|
517
|
+
source_assignment_scope_enabled=bool(assigned_source_ids),
|
|
518
|
+
traffic_class=traffic_class,
|
|
519
|
+
transport_policy_override=transport_policy_override,
|
|
520
|
+
thread_cache_identity_override=thread_cache_identity_override,
|
|
521
|
+
usage_sections=usage_sections,
|
|
522
|
+
expires_at=expires_at,
|
|
523
|
+
is_active=True,
|
|
524
|
+
created_at=now,
|
|
525
|
+
last_used_at=None,
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
created = await self._repository.create(row, commit=False)
|
|
529
|
+
|
|
530
|
+
if assigned_account_ids:
|
|
531
|
+
await self._repository.replace_account_assignments(created.id, assigned_account_ids, commit=False)
|
|
532
|
+
if assigned_source_ids:
|
|
533
|
+
await self._repository.replace_source_assignments(created.id, assigned_source_ids, commit=False)
|
|
534
|
+
|
|
535
|
+
if payload.limits:
|
|
536
|
+
limit_rows = [_limit_input_to_row(li, created.id, now) for li in payload.limits]
|
|
537
|
+
await self._repository.upsert_limits(created.id, limit_rows, commit=False)
|
|
538
|
+
|
|
539
|
+
await self._repository.commit()
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
await self._repository.rollback()
|
|
542
|
+
if isinstance(exc, IntegrityError) and _is_reasoning_policy_constraint_error(exc):
|
|
543
|
+
raise ApiKeyValidationError(
|
|
544
|
+
"enforced_reasoning_effort and allowed_reasoning_efforts cannot be configured together"
|
|
545
|
+
) from exc
|
|
546
|
+
raise
|
|
547
|
+
|
|
548
|
+
created = await self._repository.get_by_id(created.id)
|
|
549
|
+
if created is None:
|
|
550
|
+
raise ValueError("Failed to create API key")
|
|
551
|
+
|
|
552
|
+
return _to_created_data(_to_api_key_data(created), plain_key)
|
|
553
|
+
|
|
554
|
+
async def list_keys(self) -> list[ApiKeyData]:
|
|
555
|
+
rows = await self._repository.list_all()
|
|
556
|
+
usage_summary_by_key = await self._repository.list_usage_summary_by_key()
|
|
557
|
+
|
|
558
|
+
pooled_by_key: dict[str, PooledCreditData] = {}
|
|
559
|
+
if self._usage_repository is not None:
|
|
560
|
+
assigned_ids_by_key = {
|
|
561
|
+
row.id: [a.account_id for a in getattr(row, "account_assignments", [])] for row in rows
|
|
562
|
+
}
|
|
563
|
+
needs_all_accounts = any(
|
|
564
|
+
not assigned_ids_by_key[row.id] and not row.account_assignment_scope_enabled for row in rows
|
|
565
|
+
)
|
|
566
|
+
if needs_all_accounts:
|
|
567
|
+
all_accounts = await self._repository.list_all_accounts()
|
|
568
|
+
primary_usage = await self._usage_repository.latest_by_account("primary")
|
|
569
|
+
secondary_usage = await self._usage_repository.latest_by_account("secondary")
|
|
570
|
+
else:
|
|
571
|
+
all_account_ids = sorted({account_id for ids in assigned_ids_by_key.values() for account_id in ids})
|
|
572
|
+
if all_account_ids:
|
|
573
|
+
all_accounts = await self._repository.list_accounts_by_ids(all_account_ids)
|
|
574
|
+
primary_usage = await self._usage_repository.latest_by_account(
|
|
575
|
+
"primary", account_ids=all_account_ids
|
|
576
|
+
)
|
|
577
|
+
secondary_usage = await self._usage_repository.latest_by_account(
|
|
578
|
+
"secondary",
|
|
579
|
+
account_ids=all_account_ids,
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
all_accounts = []
|
|
583
|
+
primary_usage = {}
|
|
584
|
+
secondary_usage = {}
|
|
585
|
+
for row in rows:
|
|
586
|
+
pooled_by_key[row.id] = _compute_pooled_credits(
|
|
587
|
+
assigned_account_ids=assigned_ids_by_key[row.id],
|
|
588
|
+
all_accounts=all_accounts,
|
|
589
|
+
primary_usage=primary_usage,
|
|
590
|
+
secondary_usage=secondary_usage,
|
|
591
|
+
account_assignment_scope_enabled=row.account_assignment_scope_enabled,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return [
|
|
595
|
+
_to_api_key_data(
|
|
596
|
+
row,
|
|
597
|
+
usage_summary=_to_usage_summary_data(usage_summary_by_key.get(row.id)),
|
|
598
|
+
pooled_credits=pooled_by_key.get(row.id),
|
|
599
|
+
)
|
|
600
|
+
for row in rows
|
|
601
|
+
]
|
|
602
|
+
|
|
603
|
+
async def update_key(
|
|
604
|
+
self,
|
|
605
|
+
key_id: str,
|
|
606
|
+
payload: ApiKeyUpdateData,
|
|
607
|
+
*,
|
|
608
|
+
_retry_attempt: int = 0,
|
|
609
|
+
) -> ApiKeyData:
|
|
610
|
+
expires_at = _normalize_expires_at(payload.expires_at) if payload.expires_at_set else None
|
|
611
|
+
existing = await self._repository.get_by_id(key_id)
|
|
612
|
+
if existing is None:
|
|
613
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
614
|
+
|
|
615
|
+
if payload.allowed_models_set:
|
|
616
|
+
allowed_models = _normalize_allowed_models(payload.allowed_models)
|
|
617
|
+
else:
|
|
618
|
+
allowed_models = None
|
|
619
|
+
if payload.assigned_account_ids_set:
|
|
620
|
+
assigned_account_ids = await self._resolve_assigned_account_ids(payload.assigned_account_ids)
|
|
621
|
+
account_assignment_scope_enabled: bool | _Unset = bool(assigned_account_ids)
|
|
622
|
+
else:
|
|
623
|
+
assigned_account_ids = None
|
|
624
|
+
account_assignment_scope_enabled = _UNSET
|
|
625
|
+
if payload.assigned_source_ids_set:
|
|
626
|
+
assigned_source_ids = await self._resolve_assigned_source_ids(payload.assigned_source_ids)
|
|
627
|
+
source_assignment_scope_enabled: bool | _Unset = bool(assigned_source_ids)
|
|
628
|
+
else:
|
|
629
|
+
assigned_source_ids = None
|
|
630
|
+
source_assignment_scope_enabled = _UNSET
|
|
631
|
+
|
|
632
|
+
if payload.enforced_model_set:
|
|
633
|
+
enforced_model = _normalize_model_slug(payload.enforced_model)
|
|
634
|
+
else:
|
|
635
|
+
enforced_model = None
|
|
636
|
+
|
|
637
|
+
apply_to_codex_model: bool | _Unset
|
|
638
|
+
if payload.apply_to_codex_model_set:
|
|
639
|
+
if payload.apply_to_codex_model is None:
|
|
640
|
+
apply_to_codex_model = _UNSET
|
|
641
|
+
else:
|
|
642
|
+
apply_to_codex_model = payload.apply_to_codex_model
|
|
643
|
+
else:
|
|
644
|
+
apply_to_codex_model = _UNSET
|
|
645
|
+
|
|
646
|
+
if payload.enforced_reasoning_effort_set:
|
|
647
|
+
enforced_reasoning_effort = _normalize_reasoning_effort(payload.enforced_reasoning_effort)
|
|
648
|
+
else:
|
|
649
|
+
enforced_reasoning_effort = None
|
|
650
|
+
|
|
651
|
+
if payload.allowed_reasoning_efforts_set:
|
|
652
|
+
allowed_reasoning_efforts = _normalize_allowed_reasoning_efforts(payload.allowed_reasoning_efforts)
|
|
653
|
+
else:
|
|
654
|
+
allowed_reasoning_efforts = None
|
|
655
|
+
|
|
656
|
+
if payload.enforced_service_tier_set:
|
|
657
|
+
enforced_service_tier = _normalize_service_tier(payload.enforced_service_tier)
|
|
658
|
+
else:
|
|
659
|
+
enforced_service_tier = None
|
|
660
|
+
|
|
661
|
+
traffic_class_update: str | _Unset = _UNSET
|
|
662
|
+
if payload.traffic_class_set:
|
|
663
|
+
traffic_class_update = _normalize_traffic_class(payload.traffic_class)
|
|
664
|
+
transport_policy_override_update: str | None | _Unset = _UNSET
|
|
665
|
+
if payload.transport_policy_override_set:
|
|
666
|
+
transport_policy_override_update = _normalize_transport_policy_override(payload.transport_policy_override)
|
|
667
|
+
thread_cache_identity_override_update: str | None | _Unset = _UNSET
|
|
668
|
+
if payload.thread_cache_identity_override_set:
|
|
669
|
+
thread_cache_identity_override_update = _normalize_thread_cache_identity_override(
|
|
670
|
+
payload.thread_cache_identity_override
|
|
671
|
+
)
|
|
672
|
+
usage_sections: str | _Unset = _UNSET
|
|
673
|
+
if payload.usage_sections_set:
|
|
674
|
+
usage_sections = _normalize_usage_sections(payload.usage_sections)
|
|
675
|
+
|
|
676
|
+
if payload.allowed_models_set or payload.enforced_model_set:
|
|
677
|
+
effective_allowed_models = (
|
|
678
|
+
allowed_models if payload.allowed_models_set else _deserialize_allowed_models(existing.allowed_models)
|
|
679
|
+
)
|
|
680
|
+
effective_enforced_model = (
|
|
681
|
+
enforced_model if payload.enforced_model_set else _normalize_model_slug(existing.enforced_model)
|
|
682
|
+
)
|
|
683
|
+
_validate_model_enforcement(
|
|
684
|
+
enforced_model=effective_enforced_model,
|
|
685
|
+
allowed_models=effective_allowed_models,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if payload.enforced_reasoning_effort_set or payload.allowed_reasoning_efforts_set:
|
|
689
|
+
effective_enforced_reasoning_effort = (
|
|
690
|
+
enforced_reasoning_effort
|
|
691
|
+
if payload.enforced_reasoning_effort_set
|
|
692
|
+
else _normalize_reasoning_effort_lenient(existing.enforced_reasoning_effort)
|
|
693
|
+
)
|
|
694
|
+
effective_allowed_reasoning_efforts = (
|
|
695
|
+
allowed_reasoning_efforts
|
|
696
|
+
if payload.allowed_reasoning_efforts_set
|
|
697
|
+
else _deserialize_allowed_reasoning_efforts(existing.allowed_reasoning_efforts)
|
|
698
|
+
)
|
|
699
|
+
_validate_reasoning_effort_policy(
|
|
700
|
+
enforced_reasoning_effort=effective_enforced_reasoning_effort,
|
|
701
|
+
allowed_reasoning_efforts=effective_allowed_reasoning_efforts,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
limit_rows: list[ApiKeyLimit] | None = None
|
|
705
|
+
if payload.limits_set:
|
|
706
|
+
now = utcnow()
|
|
707
|
+
existing_limits = await self._repository.get_limits_by_key(key_id)
|
|
708
|
+
submitted_limits = payload.limits or []
|
|
709
|
+
limit_rows = await _build_limit_rows_for_update(
|
|
710
|
+
key_id=key_id,
|
|
711
|
+
now=now,
|
|
712
|
+
submitted_limits=submitted_limits,
|
|
713
|
+
existing_limits=existing_limits,
|
|
714
|
+
reset_usage=payload.reset_usage,
|
|
715
|
+
repository=self._repository,
|
|
716
|
+
)
|
|
717
|
+
elif payload.reset_usage:
|
|
718
|
+
now = utcnow()
|
|
719
|
+
existing_limits = await self._repository.get_limits_by_key(key_id)
|
|
720
|
+
limit_rows = _build_reset_limit_rows(key_id=key_id, now=now, existing_limits=existing_limits)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
row = await self._repository.update(
|
|
724
|
+
key_id,
|
|
725
|
+
name=_normalize_name(payload.name or "") if payload.name_set else _UNSET,
|
|
726
|
+
allowed_models=_serialize_allowed_models(allowed_models) if payload.allowed_models_set else _UNSET,
|
|
727
|
+
apply_to_codex_model=apply_to_codex_model,
|
|
728
|
+
enforced_model=enforced_model if payload.enforced_model_set else _UNSET,
|
|
729
|
+
enforced_reasoning_effort=(
|
|
730
|
+
enforced_reasoning_effort if payload.enforced_reasoning_effort_set else _UNSET
|
|
731
|
+
),
|
|
732
|
+
allowed_reasoning_efforts=(
|
|
733
|
+
_serialize_allowed_reasoning_efforts(allowed_reasoning_efforts)
|
|
734
|
+
if payload.allowed_reasoning_efforts_set
|
|
735
|
+
else _UNSET
|
|
736
|
+
),
|
|
737
|
+
enforced_service_tier=(enforced_service_tier if payload.enforced_service_tier_set else _UNSET),
|
|
738
|
+
traffic_class=traffic_class_update,
|
|
739
|
+
transport_policy_override=transport_policy_override_update,
|
|
740
|
+
thread_cache_identity_override=thread_cache_identity_override_update,
|
|
741
|
+
usage_sections=usage_sections,
|
|
742
|
+
account_assignment_scope_enabled=account_assignment_scope_enabled,
|
|
743
|
+
source_assignment_scope_enabled=source_assignment_scope_enabled,
|
|
744
|
+
expires_at=expires_at if payload.expires_at_set else _UNSET,
|
|
745
|
+
is_active=(payload.is_active if payload.is_active_set and payload.is_active is not None else _UNSET),
|
|
746
|
+
commit=False,
|
|
747
|
+
)
|
|
748
|
+
if row is None:
|
|
749
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
750
|
+
|
|
751
|
+
if payload.assigned_account_ids_set:
|
|
752
|
+
assert assigned_account_ids is not None
|
|
753
|
+
await self._repository.replace_account_assignments(key_id, assigned_account_ids, commit=False)
|
|
754
|
+
if payload.assigned_source_ids_set:
|
|
755
|
+
assert assigned_source_ids is not None
|
|
756
|
+
await self._repository.replace_source_assignments(key_id, assigned_source_ids, commit=False)
|
|
757
|
+
|
|
758
|
+
if limit_rows is not None:
|
|
759
|
+
await self._repository.upsert_limits(
|
|
760
|
+
key_id,
|
|
761
|
+
limit_rows,
|
|
762
|
+
commit=False,
|
|
763
|
+
preserve_matched_usage=not payload.reset_usage,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
await self._repository.commit()
|
|
767
|
+
except Exception as exc:
|
|
768
|
+
await self._repository.rollback()
|
|
769
|
+
if await should_retry_after_sqlite_lock(
|
|
770
|
+
exc,
|
|
771
|
+
what="update_key",
|
|
772
|
+
attempt=_retry_attempt,
|
|
773
|
+
max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
|
|
774
|
+
base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
|
|
775
|
+
):
|
|
776
|
+
return await self.update_key(key_id, payload, _retry_attempt=_retry_attempt + 1)
|
|
777
|
+
if isinstance(exc, IntegrityError) and _is_reasoning_policy_constraint_error(exc):
|
|
778
|
+
raise ApiKeyValidationError(
|
|
779
|
+
"enforced_reasoning_effort and allowed_reasoning_efforts cannot be configured together"
|
|
780
|
+
) from exc
|
|
781
|
+
raise
|
|
782
|
+
|
|
783
|
+
if (
|
|
784
|
+
payload.assigned_account_ids_set
|
|
785
|
+
or payload.assigned_source_ids_set
|
|
786
|
+
or limit_rows is not None
|
|
787
|
+
or payload.name_set
|
|
788
|
+
or payload.allowed_models_set
|
|
789
|
+
or payload.apply_to_codex_model_set
|
|
790
|
+
or payload.enforced_model_set
|
|
791
|
+
or payload.enforced_reasoning_effort_set
|
|
792
|
+
or payload.allowed_reasoning_efforts_set
|
|
793
|
+
or payload.enforced_service_tier_set
|
|
794
|
+
or payload.traffic_class_set
|
|
795
|
+
or payload.transport_policy_override_set
|
|
796
|
+
or payload.thread_cache_identity_override_set
|
|
797
|
+
or payload.usage_sections_set
|
|
798
|
+
or payload.expires_at_set
|
|
799
|
+
or payload.is_active_set
|
|
800
|
+
):
|
|
801
|
+
row = await self._repository.get_by_id(key_id)
|
|
802
|
+
if row is None:
|
|
803
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
804
|
+
|
|
805
|
+
await get_api_key_cache().invalidate(row.key_hash)
|
|
806
|
+
poller = get_cache_invalidation_poller()
|
|
807
|
+
if poller is not None:
|
|
808
|
+
await poller.bump(NAMESPACE_API_KEY)
|
|
809
|
+
return _to_api_key_data(row)
|
|
810
|
+
|
|
811
|
+
async def _resolve_assigned_account_ids(self, account_ids: list[str] | None) -> list[str]:
|
|
812
|
+
normalized_account_ids = _normalize_assigned_account_ids(account_ids)
|
|
813
|
+
existing_accounts = await self._repository.list_accounts_by_ids(normalized_account_ids)
|
|
814
|
+
existing_account_ids = {account.id for account in existing_accounts}
|
|
815
|
+
missing_account_ids = [
|
|
816
|
+
account_id for account_id in normalized_account_ids if account_id not in existing_account_ids
|
|
817
|
+
]
|
|
818
|
+
if missing_account_ids:
|
|
819
|
+
missing = ", ".join(missing_account_ids)
|
|
820
|
+
raise ApiKeyValidationError(f"Unknown account ids: {missing}")
|
|
821
|
+
return normalized_account_ids
|
|
822
|
+
|
|
823
|
+
async def _resolve_assigned_source_ids(self, source_ids: list[str] | None) -> list[str]:
|
|
824
|
+
normalized_source_ids = _normalize_assigned_source_ids(source_ids)
|
|
825
|
+
if not normalized_source_ids:
|
|
826
|
+
return []
|
|
827
|
+
existing_sources = await self._repository.list_model_sources_by_ids(normalized_source_ids)
|
|
828
|
+
existing_source_ids = {source.id for source in existing_sources}
|
|
829
|
+
missing_source_ids = [source_id for source_id in normalized_source_ids if source_id not in existing_source_ids]
|
|
830
|
+
if missing_source_ids:
|
|
831
|
+
missing = ", ".join(missing_source_ids)
|
|
832
|
+
raise ApiKeyValidationError(f"Unknown model source ids: {missing}")
|
|
833
|
+
return normalized_source_ids
|
|
834
|
+
|
|
835
|
+
async def delete_key(self, key_id: str) -> None:
|
|
836
|
+
row = await self._repository.get_by_id(key_id)
|
|
837
|
+
if row is None:
|
|
838
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
839
|
+
deleted = await self._repository.delete(key_id)
|
|
840
|
+
if not deleted:
|
|
841
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
842
|
+
await get_api_key_cache().invalidate(row.key_hash)
|
|
843
|
+
poller = get_cache_invalidation_poller()
|
|
844
|
+
if poller is not None:
|
|
845
|
+
await poller.bump(NAMESPACE_API_KEY)
|
|
846
|
+
|
|
847
|
+
async def regenerate_key(self, key_id: str) -> ApiKeyCreatedData:
|
|
848
|
+
row = await self._repository.get_by_id(key_id)
|
|
849
|
+
if row is None:
|
|
850
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
851
|
+
old_key_hash = row.key_hash
|
|
852
|
+
plain_key = _generate_plain_key()
|
|
853
|
+
updated = await self._repository.update(
|
|
854
|
+
key_id,
|
|
855
|
+
key_hash=_hash_key(plain_key),
|
|
856
|
+
key_prefix=plain_key[:15],
|
|
857
|
+
)
|
|
858
|
+
if updated is None:
|
|
859
|
+
raise ApiKeyNotFoundError(f"API key not found: {key_id}")
|
|
860
|
+
await get_api_key_cache().invalidate(old_key_hash)
|
|
861
|
+
poller = get_cache_invalidation_poller()
|
|
862
|
+
if poller is not None:
|
|
863
|
+
await poller.bump(NAMESPACE_API_KEY)
|
|
864
|
+
return _to_created_data(_to_api_key_data(updated), plain_key)
|
|
865
|
+
|
|
866
|
+
async def validate_key(self, plain_key: str) -> ApiKeyData:
|
|
867
|
+
if not plain_key:
|
|
868
|
+
raise ApiKeyInvalidError("Missing API key in Authorization header")
|
|
869
|
+
|
|
870
|
+
key_hash = _hash_key(plain_key)
|
|
871
|
+
now = utcnow()
|
|
872
|
+
row = _ensure_valid_api_key_row(await self._repository.get_by_hash(key_hash))
|
|
873
|
+
if row.expires_at is not None and row.expires_at < now:
|
|
874
|
+
raise ApiKeyInvalidError("API key has expired")
|
|
875
|
+
limits_reset = await _lazy_reset_expired_limits(self._repository, row.limits, now=now)
|
|
876
|
+
refreshed = _ensure_valid_api_key_row(await self._repository.get_by_hash(key_hash)) if limits_reset else row
|
|
877
|
+
if refreshed.expires_at is not None and refreshed.expires_at < now:
|
|
878
|
+
raise ApiKeyInvalidError("API key has expired")
|
|
879
|
+
return _to_api_key_data(refreshed)
|
|
880
|
+
|
|
881
|
+
async def get_key_by_id(self, key_id: str) -> ApiKeyData:
|
|
882
|
+
now = utcnow()
|
|
883
|
+
row = _ensure_valid_api_key_row(await self._repository.get_by_id(key_id))
|
|
884
|
+
if row.expires_at is not None and row.expires_at < now:
|
|
885
|
+
raise ApiKeyInvalidError("API key has expired")
|
|
886
|
+
return _to_api_key_data(row)
|
|
887
|
+
|
|
888
|
+
async def enforce_limits_for_request(
|
|
889
|
+
self,
|
|
890
|
+
key_id: str,
|
|
891
|
+
*,
|
|
892
|
+
request_model: str | None,
|
|
893
|
+
request_service_tier: str | None = None,
|
|
894
|
+
request_usage_budget: ApiKeyRequestUsageBudget | None = None,
|
|
895
|
+
) -> ApiKeyUsageReservationData | None:
|
|
896
|
+
for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
|
|
897
|
+
try:
|
|
898
|
+
return await self._enforce_limits_for_request_once(
|
|
899
|
+
key_id,
|
|
900
|
+
request_model=request_model,
|
|
901
|
+
request_service_tier=request_service_tier,
|
|
902
|
+
request_usage_budget=request_usage_budget,
|
|
903
|
+
)
|
|
904
|
+
except OperationalError as exc:
|
|
905
|
+
await self._repository.rollback()
|
|
906
|
+
if not await should_retry_after_sqlite_lock(
|
|
907
|
+
exc,
|
|
908
|
+
what="enforce_limits_for_request",
|
|
909
|
+
attempt=attempt,
|
|
910
|
+
max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
|
|
911
|
+
base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
|
|
912
|
+
):
|
|
913
|
+
raise
|
|
914
|
+
|
|
915
|
+
raise RuntimeError("unreachable")
|
|
916
|
+
|
|
917
|
+
async def _enforce_limits_for_request_once(
|
|
918
|
+
self,
|
|
919
|
+
key_id: str,
|
|
920
|
+
*,
|
|
921
|
+
request_model: str | None,
|
|
922
|
+
request_service_tier: str | None,
|
|
923
|
+
request_usage_budget: ApiKeyRequestUsageBudget | None,
|
|
924
|
+
) -> ApiKeyUsageReservationData | None:
|
|
925
|
+
now = utcnow()
|
|
926
|
+
async with sqlite_writer_section():
|
|
927
|
+
row = _ensure_valid_api_key_row(await self._repository.get_for_limit_enforcement(key_id))
|
|
928
|
+
if row.expires_at is not None and row.expires_at < now:
|
|
929
|
+
raise ApiKeyInvalidError("API key has expired")
|
|
930
|
+
limits_reset = await _lazy_reset_expired_limits(self._repository, row.limits, now=now)
|
|
931
|
+
refreshed = (
|
|
932
|
+
_ensure_valid_api_key_row(await self._repository.get_for_limit_enforcement(key_id))
|
|
933
|
+
if limits_reset
|
|
934
|
+
else row
|
|
935
|
+
)
|
|
936
|
+
if refreshed.expires_at is not None and refreshed.expires_at < now:
|
|
937
|
+
raise ApiKeyInvalidError("API key has expired")
|
|
938
|
+
|
|
939
|
+
reservation_items: list[UsageReservationItemData] = []
|
|
940
|
+
reservation_id: str | None = None
|
|
941
|
+
normalized_usage_budget = _normalize_request_usage_budget(request_usage_budget)
|
|
942
|
+
try:
|
|
943
|
+
for limit in refreshed.limits:
|
|
944
|
+
if not _limit_applies_for_request(limit, request_model=request_model):
|
|
945
|
+
continue
|
|
946
|
+
if limit.current_value >= limit.max_value:
|
|
947
|
+
raise _rate_limit_exceeded_error(limit)
|
|
948
|
+
reserve_delta = _reserve_delta_for_limit(
|
|
949
|
+
limit,
|
|
950
|
+
request_model=request_model,
|
|
951
|
+
request_service_tier=request_service_tier,
|
|
952
|
+
request_usage_budget=normalized_usage_budget,
|
|
953
|
+
)
|
|
954
|
+
if reserve_delta > 0:
|
|
955
|
+
result = await self._repository.try_reserve_usage(
|
|
956
|
+
limit.id,
|
|
957
|
+
delta=reserve_delta,
|
|
958
|
+
expected_reset_at=limit.reset_at,
|
|
959
|
+
)
|
|
960
|
+
if not result.success:
|
|
961
|
+
raise _rate_limit_exceeded_error(limit)
|
|
962
|
+
reservation_items.append(
|
|
963
|
+
UsageReservationItemData(
|
|
964
|
+
limit_id=limit.id,
|
|
965
|
+
limit_type=limit.limit_type,
|
|
966
|
+
reserved_delta=reserve_delta,
|
|
967
|
+
expected_reset_at=limit.reset_at,
|
|
968
|
+
)
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
if not reservation_items:
|
|
972
|
+
# No configured limit applies to this request, so there is
|
|
973
|
+
# nothing to reserve and nothing to settle. Skip the empty
|
|
974
|
+
# reservation INSERT and its full-durability commit (the
|
|
975
|
+
# lazy expired-limit reset above commits inside
|
|
976
|
+
# ``reset_limit`` itself, so no write is pending here).
|
|
977
|
+
# Every downstream consumer treats a missing reservation
|
|
978
|
+
# as "nothing to settle". Commit to close the implicit
|
|
979
|
+
# transaction opened by the admission SELECTs: on sessions
|
|
980
|
+
# that outlive this call (e.g. the quota-planner warmup
|
|
981
|
+
# service) an open transaction would otherwise idle across
|
|
982
|
+
# the upstream round-trip until the next commit. This must
|
|
983
|
+
# be ``commit()`` rather than ``rollback()``:
|
|
984
|
+
# ``AsyncSession.rollback()`` expires every tracked ORM
|
|
985
|
+
# object regardless of ``expire_on_commit``, and the warmup
|
|
986
|
+
# service shares this session with already-loaded
|
|
987
|
+
# ``account``/``decision`` rows whose attribute access
|
|
988
|
+
# after expiry raises ``MissingGreenlet``. ``commit()``
|
|
989
|
+
# with ``expire_on_commit=False`` (app/db/session.py)
|
|
990
|
+
# leaves tracked state loaded, and is semantically
|
|
991
|
+
# equivalent here because the transaction holds only the
|
|
992
|
+
# admission SELECTs — no reservation write ran, and every
|
|
993
|
+
# production caller either dedicates a session to
|
|
994
|
+
# admission (proxy paths) or commits each prior write
|
|
995
|
+
# inside its repository methods (quota-planner), so no
|
|
996
|
+
# unrelated dirty state can be flushed by this commit.
|
|
997
|
+
await self._repository.commit()
|
|
998
|
+
else:
|
|
999
|
+
reservation_id = _next_usage_reservation_id()
|
|
1000
|
+
await self._repository.create_usage_reservation(
|
|
1001
|
+
reservation_id,
|
|
1002
|
+
key_id=key_id,
|
|
1003
|
+
model=request_model or "",
|
|
1004
|
+
items=reservation_items,
|
|
1005
|
+
)
|
|
1006
|
+
await self._repository.commit()
|
|
1007
|
+
except Exception:
|
|
1008
|
+
await self._repository.rollback()
|
|
1009
|
+
raise
|
|
1010
|
+
|
|
1011
|
+
if reservation_id is None:
|
|
1012
|
+
# Settlement is the only other production writer of
|
|
1013
|
+
# ``last_used_at``; without a reservation it never runs, so record
|
|
1014
|
+
# the last-used touch at admission instead. Recorded outside
|
|
1015
|
+
# sqlite_writer_section() for the same reason as settlement: the
|
|
1016
|
+
# shutdown write-through flush takes the writer section itself.
|
|
1017
|
+
await self._last_used_coalescer.record(key_id, utcnow())
|
|
1018
|
+
return None
|
|
1019
|
+
|
|
1020
|
+
return ApiKeyUsageReservationData(
|
|
1021
|
+
reservation_id=reservation_id,
|
|
1022
|
+
key_id=key_id,
|
|
1023
|
+
model=request_model or "",
|
|
1024
|
+
has_applicable_limits=bool(reservation_items),
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
async def finalize_usage_reservation(
|
|
1028
|
+
self,
|
|
1029
|
+
reservation_id: str,
|
|
1030
|
+
*,
|
|
1031
|
+
model: str,
|
|
1032
|
+
input_tokens: int,
|
|
1033
|
+
output_tokens: int,
|
|
1034
|
+
cached_input_tokens: int = 0,
|
|
1035
|
+
service_tier: str | None = None,
|
|
1036
|
+
cost_microdollars: int | None = None,
|
|
1037
|
+
) -> None:
|
|
1038
|
+
for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
|
|
1039
|
+
try:
|
|
1040
|
+
await self._settle_usage_reservation(
|
|
1041
|
+
reservation_id,
|
|
1042
|
+
model=model,
|
|
1043
|
+
input_tokens=input_tokens,
|
|
1044
|
+
output_tokens=output_tokens,
|
|
1045
|
+
cached_input_tokens=cached_input_tokens,
|
|
1046
|
+
service_tier=service_tier,
|
|
1047
|
+
status="finalized",
|
|
1048
|
+
cost_microdollars_override=cost_microdollars,
|
|
1049
|
+
)
|
|
1050
|
+
return
|
|
1051
|
+
except OperationalError as exc:
|
|
1052
|
+
await self._repository.rollback()
|
|
1053
|
+
if not await should_retry_after_sqlite_lock(
|
|
1054
|
+
exc,
|
|
1055
|
+
what="finalize_usage_reservation",
|
|
1056
|
+
attempt=attempt,
|
|
1057
|
+
max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
|
|
1058
|
+
base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
|
|
1059
|
+
):
|
|
1060
|
+
raise
|
|
1061
|
+
|
|
1062
|
+
raise RuntimeError("unreachable")
|
|
1063
|
+
|
|
1064
|
+
async def fail_usage_reservation(
|
|
1065
|
+
self,
|
|
1066
|
+
reservation_id: str,
|
|
1067
|
+
*,
|
|
1068
|
+
model: str,
|
|
1069
|
+
input_tokens: int | None = None,
|
|
1070
|
+
output_tokens: int | None = None,
|
|
1071
|
+
cached_input_tokens: int | None = None,
|
|
1072
|
+
service_tier: str | None = None,
|
|
1073
|
+
) -> None:
|
|
1074
|
+
for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
|
|
1075
|
+
try:
|
|
1076
|
+
await self._settle_usage_reservation(
|
|
1077
|
+
reservation_id,
|
|
1078
|
+
model=model,
|
|
1079
|
+
input_tokens=input_tokens,
|
|
1080
|
+
output_tokens=output_tokens,
|
|
1081
|
+
cached_input_tokens=cached_input_tokens,
|
|
1082
|
+
service_tier=service_tier,
|
|
1083
|
+
status="failed",
|
|
1084
|
+
)
|
|
1085
|
+
return
|
|
1086
|
+
except OperationalError as exc:
|
|
1087
|
+
await self._repository.rollback()
|
|
1088
|
+
if not await should_retry_after_sqlite_lock(
|
|
1089
|
+
exc,
|
|
1090
|
+
what="fail_usage_reservation",
|
|
1091
|
+
attempt=attempt,
|
|
1092
|
+
max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
|
|
1093
|
+
base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
|
|
1094
|
+
):
|
|
1095
|
+
raise
|
|
1096
|
+
|
|
1097
|
+
raise RuntimeError("unreachable")
|
|
1098
|
+
|
|
1099
|
+
async def _settle_usage_reservation(
|
|
1100
|
+
self,
|
|
1101
|
+
reservation_id: str,
|
|
1102
|
+
*,
|
|
1103
|
+
model: str,
|
|
1104
|
+
input_tokens: int | None,
|
|
1105
|
+
output_tokens: int | None,
|
|
1106
|
+
cached_input_tokens: int | None,
|
|
1107
|
+
service_tier: str | None,
|
|
1108
|
+
status: str,
|
|
1109
|
+
cost_microdollars_override: int | None = None,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
async with sqlite_writer_section():
|
|
1112
|
+
reservation = await self._repository.get_usage_reservation(reservation_id)
|
|
1113
|
+
if reservation is None or reservation.status != "reserved":
|
|
1114
|
+
return
|
|
1115
|
+
|
|
1116
|
+
claimed = await self._repository.transition_usage_reservation_status(
|
|
1117
|
+
reservation_id,
|
|
1118
|
+
expected_status="reserved",
|
|
1119
|
+
new_status="settling",
|
|
1120
|
+
)
|
|
1121
|
+
if not claimed:
|
|
1122
|
+
await self._repository.rollback()
|
|
1123
|
+
return
|
|
1124
|
+
|
|
1125
|
+
effective_input_tokens = input_tokens or 0
|
|
1126
|
+
effective_output_tokens = output_tokens or 0
|
|
1127
|
+
effective_cached_input_tokens = cached_input_tokens or 0
|
|
1128
|
+
cost_microdollars = (
|
|
1129
|
+
cost_microdollars_override
|
|
1130
|
+
if cost_microdollars_override is not None
|
|
1131
|
+
else _calculate_cost_microdollars(
|
|
1132
|
+
model,
|
|
1133
|
+
effective_input_tokens,
|
|
1134
|
+
effective_output_tokens,
|
|
1135
|
+
effective_cached_input_tokens,
|
|
1136
|
+
service_tier,
|
|
1137
|
+
)
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
try:
|
|
1141
|
+
for item in reservation.items:
|
|
1142
|
+
actual_delta = _compute_increment_for_limit_type(
|
|
1143
|
+
item.limit_type,
|
|
1144
|
+
input_tokens=effective_input_tokens,
|
|
1145
|
+
output_tokens=effective_output_tokens,
|
|
1146
|
+
cost_microdollars=cost_microdollars,
|
|
1147
|
+
)
|
|
1148
|
+
delta = actual_delta - item.reserved_delta
|
|
1149
|
+
if delta != 0:
|
|
1150
|
+
await self._repository.adjust_reserved_usage(
|
|
1151
|
+
item.limit_id,
|
|
1152
|
+
delta=delta,
|
|
1153
|
+
expected_reset_at=item.expected_reset_at,
|
|
1154
|
+
)
|
|
1155
|
+
await self._repository.upsert_reservation_item_actual(
|
|
1156
|
+
reservation_id,
|
|
1157
|
+
item=item,
|
|
1158
|
+
actual_delta=actual_delta,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
await self._repository.settle_usage_reservation(
|
|
1162
|
+
reservation_id,
|
|
1163
|
+
status=status,
|
|
1164
|
+
input_tokens=input_tokens,
|
|
1165
|
+
output_tokens=output_tokens,
|
|
1166
|
+
cached_input_tokens=cached_input_tokens,
|
|
1167
|
+
cost_microdollars=cost_microdollars,
|
|
1168
|
+
)
|
|
1169
|
+
await self._repository.commit()
|
|
1170
|
+
except Exception:
|
|
1171
|
+
await self._repository.rollback()
|
|
1172
|
+
raise
|
|
1173
|
+
|
|
1174
|
+
# Write-behind: the periodic flusher persists last_used_at (coalesced,
|
|
1175
|
+
# greatest-wins) instead of this settlement commit. Recorded outside
|
|
1176
|
+
# sqlite_writer_section(): during shutdown write-through the record
|
|
1177
|
+
# flushes immediately, and that flush takes the writer section itself.
|
|
1178
|
+
await self._last_used_coalescer.record(reservation.api_key_id, utcnow())
|
|
1179
|
+
|
|
1180
|
+
async def touch_usage_reservation(self, reservation_id: str) -> bool:
|
|
1181
|
+
for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
|
|
1182
|
+
try:
|
|
1183
|
+
async with sqlite_writer_section():
|
|
1184
|
+
touched = await self._repository.touch_usage_reservation(reservation_id)
|
|
1185
|
+
await self._repository.commit()
|
|
1186
|
+
return touched
|
|
1187
|
+
except OperationalError as exc:
|
|
1188
|
+
await self._repository.rollback()
|
|
1189
|
+
if not await should_retry_after_sqlite_lock(
|
|
1190
|
+
exc,
|
|
1191
|
+
what="touch_usage_reservation",
|
|
1192
|
+
attempt=attempt,
|
|
1193
|
+
max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
|
|
1194
|
+
base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
|
|
1195
|
+
):
|
|
1196
|
+
raise
|
|
1197
|
+
|
|
1198
|
+
raise RuntimeError("unreachable")
|
|
1199
|
+
|
|
1200
|
+
async def release_usage_reservation(self, reservation_id: str) -> None:
|
|
1201
|
+
for attempt in range(_SQLITE_BUSY_RETRY_ATTEMPTS):
|
|
1202
|
+
try:
|
|
1203
|
+
await self._release_usage_reservation_once(reservation_id)
|
|
1204
|
+
return
|
|
1205
|
+
except OperationalError as exc:
|
|
1206
|
+
await self._repository.rollback()
|
|
1207
|
+
if not await should_retry_after_sqlite_lock(
|
|
1208
|
+
exc,
|
|
1209
|
+
what="release_usage_reservation",
|
|
1210
|
+
attempt=attempt,
|
|
1211
|
+
max_attempts=_SQLITE_BUSY_RETRY_ATTEMPTS,
|
|
1212
|
+
base_delay_seconds=_SQLITE_BUSY_RETRY_BASE_SECONDS,
|
|
1213
|
+
):
|
|
1214
|
+
raise
|
|
1215
|
+
|
|
1216
|
+
raise RuntimeError("unreachable")
|
|
1217
|
+
|
|
1218
|
+
async def _release_usage_reservation_once(self, reservation_id: str) -> None:
|
|
1219
|
+
async with sqlite_writer_section():
|
|
1220
|
+
reservation = await self._repository.get_usage_reservation(reservation_id)
|
|
1221
|
+
if reservation is None or reservation.status != "reserved":
|
|
1222
|
+
return
|
|
1223
|
+
|
|
1224
|
+
claimed = await self._repository.transition_usage_reservation_status(
|
|
1225
|
+
reservation_id,
|
|
1226
|
+
expected_status="reserved",
|
|
1227
|
+
new_status="released",
|
|
1228
|
+
)
|
|
1229
|
+
if not claimed:
|
|
1230
|
+
await self._repository.rollback()
|
|
1231
|
+
return
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
for item in reservation.items:
|
|
1235
|
+
await self._repository.adjust_reserved_usage(
|
|
1236
|
+
item.limit_id,
|
|
1237
|
+
delta=-item.reserved_delta,
|
|
1238
|
+
expected_reset_at=item.expected_reset_at,
|
|
1239
|
+
)
|
|
1240
|
+
await self._repository.upsert_reservation_item_actual(
|
|
1241
|
+
reservation_id,
|
|
1242
|
+
item=item,
|
|
1243
|
+
actual_delta=0,
|
|
1244
|
+
)
|
|
1245
|
+
await self._repository.settle_usage_reservation(
|
|
1246
|
+
reservation_id,
|
|
1247
|
+
status="released",
|
|
1248
|
+
input_tokens=None,
|
|
1249
|
+
output_tokens=None,
|
|
1250
|
+
cached_input_tokens=None,
|
|
1251
|
+
cost_microdollars=None,
|
|
1252
|
+
)
|
|
1253
|
+
await self._repository.commit()
|
|
1254
|
+
except Exception:
|
|
1255
|
+
await self._repository.rollback()
|
|
1256
|
+
raise
|
|
1257
|
+
|
|
1258
|
+
async def record_usage(
|
|
1259
|
+
self,
|
|
1260
|
+
key_id: str,
|
|
1261
|
+
*,
|
|
1262
|
+
model: str,
|
|
1263
|
+
input_tokens: int,
|
|
1264
|
+
output_tokens: int,
|
|
1265
|
+
cached_input_tokens: int = 0,
|
|
1266
|
+
service_tier: str | None = None,
|
|
1267
|
+
) -> None:
|
|
1268
|
+
cost_microdollars = _calculate_cost_microdollars(
|
|
1269
|
+
model,
|
|
1270
|
+
input_tokens,
|
|
1271
|
+
output_tokens,
|
|
1272
|
+
cached_input_tokens,
|
|
1273
|
+
service_tier,
|
|
1274
|
+
)
|
|
1275
|
+
await self._repository.increment_limit_usage(
|
|
1276
|
+
key_id,
|
|
1277
|
+
model=model,
|
|
1278
|
+
input_tokens=input_tokens,
|
|
1279
|
+
output_tokens=output_tokens,
|
|
1280
|
+
cost_microdollars=cost_microdollars,
|
|
1281
|
+
)
|
|
1282
|
+
await self._last_used_coalescer.record(key_id, utcnow())
|
|
1283
|
+
|
|
1284
|
+
async def get_key_trends(self, key_id: str) -> ApiKeyTrendsData | None:
|
|
1285
|
+
row = await self._repository.get_by_id(key_id)
|
|
1286
|
+
if row is None:
|
|
1287
|
+
return None
|
|
1288
|
+
now = utcnow()
|
|
1289
|
+
since = now - timedelta(days=_SPARKLINE_DAYS)
|
|
1290
|
+
buckets = await self._repository.trends_by_key(
|
|
1291
|
+
key_id,
|
|
1292
|
+
since,
|
|
1293
|
+
now,
|
|
1294
|
+
_DETAIL_BUCKET_SECONDS,
|
|
1295
|
+
)
|
|
1296
|
+
return _build_api_key_trends(key_id, buckets, since, now, _DETAIL_BUCKET_SECONDS)
|
|
1297
|
+
|
|
1298
|
+
async def get_key_usage_summary_for_self(self, key_id: str) -> ApiKeySelfUsageData | None:
|
|
1299
|
+
"""Return usage summary + current limits for a single key (self-service lookup)."""
|
|
1300
|
+
row = await self._repository.get_by_id(key_id)
|
|
1301
|
+
if row is None:
|
|
1302
|
+
return None
|
|
1303
|
+
|
|
1304
|
+
now = utcnow()
|
|
1305
|
+
# Reset any expired limits before reading state
|
|
1306
|
+
limits_reset = await _lazy_reset_expired_limits(self._repository, row.limits, now=now)
|
|
1307
|
+
refreshed = await self._repository.get_by_id(key_id) if limits_reset else row
|
|
1308
|
+
if refreshed is None:
|
|
1309
|
+
return None
|
|
1310
|
+
|
|
1311
|
+
usage = await self._repository.get_usage_summary_by_key_id(key_id)
|
|
1312
|
+
limits = [
|
|
1313
|
+
ApiKeySelfLimitData(
|
|
1314
|
+
limit_type=limit.limit_type.value,
|
|
1315
|
+
limit_window=limit.limit_window.value,
|
|
1316
|
+
max_value=limit.max_value,
|
|
1317
|
+
current_value=max(0, min(limit.current_value, limit.max_value)),
|
|
1318
|
+
remaining_value=max(0, limit.max_value - max(0, min(limit.current_value, limit.max_value))),
|
|
1319
|
+
model_filter=limit.model_filter,
|
|
1320
|
+
reset_at=limit.reset_at,
|
|
1321
|
+
source="api_key_limit",
|
|
1322
|
+
)
|
|
1323
|
+
for limit in refreshed.limits
|
|
1324
|
+
]
|
|
1325
|
+
return ApiKeySelfUsageData(
|
|
1326
|
+
request_count=usage.request_count,
|
|
1327
|
+
total_tokens=usage.total_tokens,
|
|
1328
|
+
cached_input_tokens=usage.cached_input_tokens,
|
|
1329
|
+
total_cost_usd=usage.total_cost_usd,
|
|
1330
|
+
limits=limits,
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
async def get_key_usage_7d(self, key_id: str) -> ApiKeyUsage7DayData | None:
|
|
1334
|
+
row = await self._repository.get_by_id(key_id)
|
|
1335
|
+
if row is None:
|
|
1336
|
+
return None
|
|
1337
|
+
now = utcnow()
|
|
1338
|
+
since = now - timedelta(days=7)
|
|
1339
|
+
data = await self._repository.usage_7d(key_id, since, now)
|
|
1340
|
+
return ApiKeyUsage7DayData(
|
|
1341
|
+
key_id=key_id,
|
|
1342
|
+
total_tokens=data.total_tokens,
|
|
1343
|
+
total_cost_usd=data.total_cost_usd,
|
|
1344
|
+
total_requests=data.total_requests,
|
|
1345
|
+
cached_input_tokens=data.cached_input_tokens,
|
|
1346
|
+
account_costs=[
|
|
1347
|
+
ApiKeyAccountCostData(
|
|
1348
|
+
account_id=ac.account_id,
|
|
1349
|
+
email=ac.email,
|
|
1350
|
+
cost_usd=ac.cost_usd,
|
|
1351
|
+
is_deleted=ac.is_deleted,
|
|
1352
|
+
)
|
|
1353
|
+
for ac in data.account_costs
|
|
1354
|
+
],
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
@dataclass(frozen=True, slots=True)
|
|
1359
|
+
class ApiKeyTrendsPoint:
|
|
1360
|
+
t: datetime
|
|
1361
|
+
v: float
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
@dataclass(frozen=True, slots=True)
|
|
1365
|
+
class ApiKeyTrendsData:
|
|
1366
|
+
key_id: str
|
|
1367
|
+
cost: list[ApiKeyTrendsPoint] = field(default_factory=list)
|
|
1368
|
+
tokens: list[ApiKeyTrendsPoint] = field(default_factory=list)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
@dataclass(frozen=True, slots=True)
|
|
1372
|
+
class ApiKeyAccountCostData:
|
|
1373
|
+
account_id: str | None
|
|
1374
|
+
email: str | None
|
|
1375
|
+
cost_usd: float
|
|
1376
|
+
is_deleted: bool = False
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
@dataclass(frozen=True, slots=True)
|
|
1380
|
+
class ApiKeyUsage7DayData:
|
|
1381
|
+
key_id: str
|
|
1382
|
+
total_tokens: int = 0
|
|
1383
|
+
total_cost_usd: float = 0.0
|
|
1384
|
+
total_requests: int = 0
|
|
1385
|
+
cached_input_tokens: int = 0
|
|
1386
|
+
account_costs: list[ApiKeyAccountCostData] = field(default_factory=list)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
@dataclass(frozen=True, slots=True)
|
|
1390
|
+
class ApiKeySelfLimitData:
|
|
1391
|
+
limit_type: str
|
|
1392
|
+
limit_window: str
|
|
1393
|
+
max_value: int
|
|
1394
|
+
current_value: int
|
|
1395
|
+
remaining_value: int
|
|
1396
|
+
model_filter: str | None
|
|
1397
|
+
reset_at: datetime
|
|
1398
|
+
source: str = "api_key_limit"
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
@dataclass(frozen=True, slots=True)
|
|
1402
|
+
class ApiKeySelfUsageData:
|
|
1403
|
+
request_count: int = 0
|
|
1404
|
+
total_tokens: int = 0
|
|
1405
|
+
cached_input_tokens: int = 0
|
|
1406
|
+
total_cost_usd: float = 0.0
|
|
1407
|
+
limits: list[ApiKeySelfLimitData] = field(default_factory=list)
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def _normalize_name(name: str) -> str:
|
|
1411
|
+
normalized = name.strip()
|
|
1412
|
+
if not normalized:
|
|
1413
|
+
raise ApiKeyValidationError("API key name is required")
|
|
1414
|
+
return normalized
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
_VALID_USAGE_SECTIONS = {"upstream_limits", "account_pool_usage"}
|
|
1418
|
+
_DEFAULT_USAGE_SECTIONS = "upstream_limits,account_pool_usage"
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _normalize_usage_sections(raw: str | None) -> str:
|
|
1422
|
+
if raw is None:
|
|
1423
|
+
return _DEFAULT_USAGE_SECTIONS
|
|
1424
|
+
if not raw.strip():
|
|
1425
|
+
return ""
|
|
1426
|
+
sections = [s.strip() for s in raw.split(",") if s.strip()]
|
|
1427
|
+
invalid = [s for s in sections if s not in _VALID_USAGE_SECTIONS]
|
|
1428
|
+
if invalid:
|
|
1429
|
+
raise ApiKeyValidationError(f"Invalid usage sections: {', '.join(invalid)}")
|
|
1430
|
+
return ",".join(dict.fromkeys(sections))
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _get_usage_sections_with_default(row: ApiKey) -> str:
|
|
1434
|
+
value = getattr(row, "usage_sections", None)
|
|
1435
|
+
if value is None:
|
|
1436
|
+
return _DEFAULT_USAGE_SECTIONS
|
|
1437
|
+
return value
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _generate_plain_key() -> str:
|
|
1441
|
+
return f"sk-clb-{secrets.token_hex(24)}"
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _hash_key(plain_key: str) -> str:
|
|
1445
|
+
return sha256(plain_key.encode("utf-8")).hexdigest()
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _serialize_allowed_models(allowed_models: list[str] | None) -> str | None:
|
|
1449
|
+
if allowed_models is None:
|
|
1450
|
+
return None
|
|
1451
|
+
return json.dumps(allowed_models)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _serialize_allowed_reasoning_efforts(allowed_reasoning_efforts: list[str] | None) -> str | None:
|
|
1455
|
+
if allowed_reasoning_efforts is None:
|
|
1456
|
+
return None
|
|
1457
|
+
return json.dumps(allowed_reasoning_efforts)
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _deserialize_allowed_models(payload: str | None) -> list[str] | None:
|
|
1461
|
+
if payload is None:
|
|
1462
|
+
return None
|
|
1463
|
+
parsed = json.loads(payload)
|
|
1464
|
+
if not isinstance(parsed, list):
|
|
1465
|
+
return None
|
|
1466
|
+
models = [value.strip() for value in parsed if isinstance(value, str) and value.strip()]
|
|
1467
|
+
return models
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _deserialize_allowed_reasoning_efforts(payload: str | None) -> list[str] | None:
|
|
1471
|
+
if payload is None:
|
|
1472
|
+
return None
|
|
1473
|
+
try:
|
|
1474
|
+
parsed = json.loads(payload)
|
|
1475
|
+
if not isinstance(parsed, list):
|
|
1476
|
+
return []
|
|
1477
|
+
return _normalize_allowed_reasoning_efforts(parsed)
|
|
1478
|
+
except (ApiKeyValidationError, TypeError, json.JSONDecodeError):
|
|
1479
|
+
return []
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _is_reasoning_policy_constraint_error(exc: IntegrityError) -> bool:
|
|
1483
|
+
return _REASONING_POLICY_EXCLUSIVE_CONSTRAINT in str(exc).lower()
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _normalize_allowed_models(allowed_models: list[str] | None) -> list[str] | None:
|
|
1487
|
+
if allowed_models is None:
|
|
1488
|
+
return None
|
|
1489
|
+
return [model.strip() for model in allowed_models if model and model.strip()]
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _normalize_assigned_account_ids(account_ids: list[str] | None) -> list[str]:
|
|
1493
|
+
if not account_ids:
|
|
1494
|
+
return []
|
|
1495
|
+
normalized: list[str] = []
|
|
1496
|
+
seen: set[str] = set()
|
|
1497
|
+
for account_id in account_ids:
|
|
1498
|
+
value = account_id.strip()
|
|
1499
|
+
if not value or value in seen:
|
|
1500
|
+
continue
|
|
1501
|
+
normalized.append(value)
|
|
1502
|
+
seen.add(value)
|
|
1503
|
+
return normalized
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def _normalize_assigned_source_ids(source_ids: list[str] | None) -> list[str]:
|
|
1507
|
+
if not source_ids:
|
|
1508
|
+
return []
|
|
1509
|
+
normalized: list[str] = []
|
|
1510
|
+
seen: set[str] = set()
|
|
1511
|
+
for source_id in source_ids:
|
|
1512
|
+
value = source_id.strip()
|
|
1513
|
+
if not value or value in seen:
|
|
1514
|
+
continue
|
|
1515
|
+
normalized.append(value)
|
|
1516
|
+
seen.add(value)
|
|
1517
|
+
return normalized
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def _normalize_model_slug(value: str | None) -> str | None:
|
|
1521
|
+
if value is None:
|
|
1522
|
+
return None
|
|
1523
|
+
normalized = value.strip()
|
|
1524
|
+
if not normalized:
|
|
1525
|
+
return None
|
|
1526
|
+
return normalized
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
_REASONING_EFFORT_ORDER = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
|
1530
|
+
_SUPPORTED_REASONING_EFFORTS = frozenset({"none", *_REASONING_EFFORT_ORDER})
|
|
1531
|
+
_SUPPORTED_SELECTABLE_REASONING_EFFORTS = frozenset(_REASONING_EFFORT_ORDER)
|
|
1532
|
+
_SUPPORTED_SERVICE_TIERS = frozenset({"auto", "default", "priority", "flex", "ultrafast"})
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _normalize_expires_at(value: datetime | None) -> datetime | None:
|
|
1536
|
+
if value is None:
|
|
1537
|
+
return None
|
|
1538
|
+
return to_utc_naive(value)
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _normalize_reasoning_effort(value: str | None) -> str | None:
|
|
1542
|
+
if value is None:
|
|
1543
|
+
return None
|
|
1544
|
+
normalized = value.strip().lower()
|
|
1545
|
+
if not normalized:
|
|
1546
|
+
return None
|
|
1547
|
+
if normalized not in _SUPPORTED_REASONING_EFFORTS:
|
|
1548
|
+
options = ", ".join(sorted(_SUPPORTED_REASONING_EFFORTS))
|
|
1549
|
+
raise ApiKeyValidationError(f"Unsupported enforced reasoning effort '{normalized}'. Expected one of: {options}")
|
|
1550
|
+
return normalized
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _normalize_reasoning_effort_lenient(value: str | None) -> str | None:
|
|
1554
|
+
if value is None:
|
|
1555
|
+
return None
|
|
1556
|
+
normalized = value.strip().lower()
|
|
1557
|
+
if not normalized:
|
|
1558
|
+
return None
|
|
1559
|
+
if normalized in _SUPPORTED_REASONING_EFFORTS:
|
|
1560
|
+
return normalized
|
|
1561
|
+
return None
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _normalize_allowed_reasoning_efforts(values: list[str] | None) -> list[str] | None:
|
|
1565
|
+
if values is None:
|
|
1566
|
+
return None
|
|
1567
|
+
|
|
1568
|
+
normalized: set[str] = set()
|
|
1569
|
+
for value in values:
|
|
1570
|
+
if not isinstance(value, str):
|
|
1571
|
+
raise ApiKeyValidationError("Allowed reasoning efforts must be strings")
|
|
1572
|
+
effort = value.strip().lower()
|
|
1573
|
+
if effort not in _SUPPORTED_SELECTABLE_REASONING_EFFORTS:
|
|
1574
|
+
options = ", ".join(_REASONING_EFFORT_ORDER)
|
|
1575
|
+
raise ApiKeyValidationError(f"Unsupported allowed reasoning effort '{effort}'. Expected one of: {options}")
|
|
1576
|
+
normalized.add(effort)
|
|
1577
|
+
|
|
1578
|
+
if not normalized:
|
|
1579
|
+
raise ApiKeyValidationError("Allowed reasoning efforts must not be empty")
|
|
1580
|
+
return [effort for effort in _REASONING_EFFORT_ORDER if effort in normalized]
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def _normalize_service_tier(value: str | None) -> str | None:
|
|
1584
|
+
if value is None:
|
|
1585
|
+
return None
|
|
1586
|
+
normalized = value.strip().lower()
|
|
1587
|
+
if not normalized:
|
|
1588
|
+
return None
|
|
1589
|
+
if normalized == "fast":
|
|
1590
|
+
normalized = "priority"
|
|
1591
|
+
if normalized not in _SUPPORTED_SERVICE_TIERS:
|
|
1592
|
+
options = ", ".join(sorted(_SUPPORTED_SERVICE_TIERS | {"fast"}))
|
|
1593
|
+
raise ApiKeyValidationError(f"Unsupported enforced service tier '{normalized}'. Expected one of: {options}")
|
|
1594
|
+
return normalized
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _normalize_service_tier_lenient(value: str | None) -> str | None:
|
|
1598
|
+
if value is None:
|
|
1599
|
+
return None
|
|
1600
|
+
normalized = value.strip().lower()
|
|
1601
|
+
if not normalized:
|
|
1602
|
+
return None
|
|
1603
|
+
if normalized == "fast":
|
|
1604
|
+
return "priority"
|
|
1605
|
+
if normalized in _SUPPORTED_SERVICE_TIERS:
|
|
1606
|
+
return normalized
|
|
1607
|
+
return None
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _normalize_traffic_class(value: str | None) -> str:
|
|
1611
|
+
normalized = (value or TRAFFIC_CLASS_FOREGROUND).strip().lower()
|
|
1612
|
+
if normalized not in _SUPPORTED_TRAFFIC_CLASSES:
|
|
1613
|
+
options = ", ".join(sorted(_SUPPORTED_TRAFFIC_CLASSES))
|
|
1614
|
+
raise ValueError(f"Unsupported traffic class '{normalized}'. Expected one of: {options}")
|
|
1615
|
+
return normalized
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _normalize_traffic_class_lenient(value: str | None) -> str:
|
|
1619
|
+
normalized = (value or TRAFFIC_CLASS_FOREGROUND).strip().lower()
|
|
1620
|
+
if normalized in _SUPPORTED_TRAFFIC_CLASSES:
|
|
1621
|
+
return normalized
|
|
1622
|
+
return TRAFFIC_CLASS_FOREGROUND
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _normalize_thread_cache_identity_override(value: str | None) -> str | None:
|
|
1626
|
+
"""Strict (write path): an unknown value is a 400, not a silent fallback."""
|
|
1627
|
+
if value is None:
|
|
1628
|
+
return None
|
|
1629
|
+
normalized = normalize_thread_cache_identity_mode(value)
|
|
1630
|
+
if normalized is not None:
|
|
1631
|
+
return normalized
|
|
1632
|
+
options = ", ".join(sorted(THREAD_CACHE_IDENTITY_MODES))
|
|
1633
|
+
raise ApiKeyValidationError(
|
|
1634
|
+
f"Unsupported thread cache identity override '{value.strip().lower()}'. Expected one of: {options}"
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _normalize_thread_cache_identity_override_lenient(value: str | None) -> str | None:
|
|
1639
|
+
"""Read path: a stale or hand-edited value reads as "no override" rather than a 500."""
|
|
1640
|
+
return normalize_thread_cache_identity_mode(value)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _normalize_transport_policy_override(value: str | None) -> str | None:
|
|
1644
|
+
if value is None:
|
|
1645
|
+
return None
|
|
1646
|
+
normalized = value.strip().lower()
|
|
1647
|
+
if normalized in _SUPPORTED_TRANSPORT_POLICY_OVERRIDES:
|
|
1648
|
+
return normalized
|
|
1649
|
+
options = ", ".join(sorted(_SUPPORTED_TRANSPORT_POLICY_OVERRIDES))
|
|
1650
|
+
raise ApiKeyValidationError(f"Unsupported transport policy override '{normalized}'. Expected one of: {options}")
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def _normalize_transport_policy_override_lenient(value: str | None) -> str | None:
|
|
1654
|
+
if value is None:
|
|
1655
|
+
return None
|
|
1656
|
+
normalized = value.strip().lower()
|
|
1657
|
+
if normalized in _SUPPORTED_TRANSPORT_POLICY_OVERRIDES:
|
|
1658
|
+
return normalized
|
|
1659
|
+
return None
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def _validate_model_enforcement(*, enforced_model: str | None, allowed_models: list[str] | None) -> None:
|
|
1663
|
+
if enforced_model is None or not allowed_models:
|
|
1664
|
+
return
|
|
1665
|
+
if enforced_model not in allowed_models:
|
|
1666
|
+
raise ApiKeyValidationError(
|
|
1667
|
+
"enforced_model must be present in allowed_models when allowed_models is configured"
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _validate_reasoning_effort_policy(
|
|
1672
|
+
*,
|
|
1673
|
+
enforced_reasoning_effort: str | None,
|
|
1674
|
+
allowed_reasoning_efforts: list[str] | None,
|
|
1675
|
+
) -> None:
|
|
1676
|
+
if enforced_reasoning_effort is not None and allowed_reasoning_efforts is not None:
|
|
1677
|
+
raise ApiKeyValidationError(
|
|
1678
|
+
"enforced_reasoning_effort and allowed_reasoning_efforts cannot be configured together"
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _to_limit_rule_data(limit: ApiKeyLimit) -> LimitRuleData:
|
|
1683
|
+
return LimitRuleData(
|
|
1684
|
+
id=limit.id,
|
|
1685
|
+
limit_type=limit.limit_type.value,
|
|
1686
|
+
limit_window=limit.limit_window.value,
|
|
1687
|
+
max_value=limit.max_value,
|
|
1688
|
+
current_value=limit.current_value,
|
|
1689
|
+
model_filter=limit.model_filter,
|
|
1690
|
+
reset_at=limit.reset_at,
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _ensure_valid_api_key_row(row: ApiKey | None) -> ApiKey:
|
|
1695
|
+
if row is None or not row.is_active:
|
|
1696
|
+
raise ApiKeyInvalidError("Invalid API key")
|
|
1697
|
+
return row
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
async def _lazy_reset_expired_limits(
|
|
1701
|
+
repository: ApiKeysRepositoryProtocol,
|
|
1702
|
+
limits: list[ApiKeyLimit],
|
|
1703
|
+
*,
|
|
1704
|
+
now: datetime,
|
|
1705
|
+
) -> bool:
|
|
1706
|
+
reset_performed = False
|
|
1707
|
+
for limit in limits:
|
|
1708
|
+
if limit.reset_at >= now:
|
|
1709
|
+
continue
|
|
1710
|
+
new_reset_at = advance_limit_reset(limit.reset_at, now, limit.limit_window)
|
|
1711
|
+
await repository.reset_limit(
|
|
1712
|
+
limit.id,
|
|
1713
|
+
expected_reset_at=limit.reset_at,
|
|
1714
|
+
new_reset_at=new_reset_at,
|
|
1715
|
+
)
|
|
1716
|
+
reset_performed = True
|
|
1717
|
+
return reset_performed
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _rate_limit_exceeded_error(limit: ApiKeyLimit) -> ApiKeyRateLimitExceededError:
|
|
1721
|
+
return ApiKeyRateLimitExceededError(
|
|
1722
|
+
message=f"API key {limit.limit_type.value} {limit.limit_window.value} limit exceeded"
|
|
1723
|
+
+ (f" for model {limit.model_filter}" if limit.model_filter else ""),
|
|
1724
|
+
reset_at=limit.reset_at,
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _limit_applies_for_request(limit: ApiKeyLimit, *, request_model: str | None) -> bool:
|
|
1729
|
+
if limit.model_filter is None:
|
|
1730
|
+
return True
|
|
1731
|
+
if request_model is None:
|
|
1732
|
+
return False
|
|
1733
|
+
return limit.model_filter == request_model
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _reserve_delta_for_limit(
|
|
1737
|
+
limit: ApiKeyLimit,
|
|
1738
|
+
*,
|
|
1739
|
+
request_model: str | None,
|
|
1740
|
+
request_service_tier: str | None,
|
|
1741
|
+
request_usage_budget: ApiKeyRequestUsageBudget,
|
|
1742
|
+
) -> int:
|
|
1743
|
+
remaining = limit.max_value - limit.current_value
|
|
1744
|
+
if remaining <= 0:
|
|
1745
|
+
return 0
|
|
1746
|
+
budget = _reserve_budget_for_limit_type(
|
|
1747
|
+
limit.limit_type,
|
|
1748
|
+
request_model=request_model,
|
|
1749
|
+
request_service_tier=request_service_tier,
|
|
1750
|
+
request_usage_budget=request_usage_budget,
|
|
1751
|
+
)
|
|
1752
|
+
return min(remaining, budget)
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def _normalize_request_usage_budget(budget: ApiKeyRequestUsageBudget | None) -> ApiKeyRequestUsageBudget:
|
|
1756
|
+
if budget is None:
|
|
1757
|
+
return ApiKeyRequestUsageBudget(
|
|
1758
|
+
input_tokens=API_KEY_USAGE_RESERVATION_DEFAULT_INPUT_TOKENS,
|
|
1759
|
+
output_tokens=API_KEY_USAGE_RESERVATION_DEFAULT_OUTPUT_TOKENS,
|
|
1760
|
+
)
|
|
1761
|
+
return ApiKeyRequestUsageBudget(
|
|
1762
|
+
input_tokens=_normalize_reservation_token_budget(
|
|
1763
|
+
budget.input_tokens,
|
|
1764
|
+
default=API_KEY_USAGE_RESERVATION_DEFAULT_INPUT_TOKENS,
|
|
1765
|
+
),
|
|
1766
|
+
output_tokens=_normalize_reservation_token_budget(
|
|
1767
|
+
budget.output_tokens,
|
|
1768
|
+
default=API_KEY_USAGE_RESERVATION_DEFAULT_OUTPUT_TOKENS,
|
|
1769
|
+
),
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def _normalize_reservation_token_budget(value: int | None, *, default: int) -> int:
|
|
1774
|
+
if value is None:
|
|
1775
|
+
return default
|
|
1776
|
+
return max(0, min(value, API_KEY_USAGE_RESERVATION_MAX_TOKEN_BUDGET))
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _reserve_budget_for_limit_type(
|
|
1780
|
+
limit_type: LimitType,
|
|
1781
|
+
*,
|
|
1782
|
+
request_model: str | None,
|
|
1783
|
+
request_service_tier: str | None,
|
|
1784
|
+
request_usage_budget: ApiKeyRequestUsageBudget,
|
|
1785
|
+
) -> int:
|
|
1786
|
+
input_tokens = request_usage_budget.input_tokens or 0
|
|
1787
|
+
output_tokens = request_usage_budget.output_tokens or 0
|
|
1788
|
+
if limit_type == LimitType.TOTAL_TOKENS:
|
|
1789
|
+
return input_tokens + output_tokens
|
|
1790
|
+
if limit_type == LimitType.INPUT_TOKENS:
|
|
1791
|
+
return input_tokens
|
|
1792
|
+
if limit_type == LimitType.OUTPUT_TOKENS:
|
|
1793
|
+
return output_tokens
|
|
1794
|
+
if limit_type == LimitType.COST_USD:
|
|
1795
|
+
return _reserve_cost_budget_microdollars(
|
|
1796
|
+
request_model,
|
|
1797
|
+
request_service_tier,
|
|
1798
|
+
input_tokens=input_tokens,
|
|
1799
|
+
output_tokens=output_tokens,
|
|
1800
|
+
)
|
|
1801
|
+
if limit_type == LimitType.CREDITS:
|
|
1802
|
+
return 0
|
|
1803
|
+
return 1
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _reserve_cost_budget_microdollars(
|
|
1807
|
+
model: str | None,
|
|
1808
|
+
service_tier: str | None,
|
|
1809
|
+
*,
|
|
1810
|
+
input_tokens: int,
|
|
1811
|
+
output_tokens: int,
|
|
1812
|
+
) -> int:
|
|
1813
|
+
if not model:
|
|
1814
|
+
return _unknown_model_reserve_cost_budget_microdollars(input_tokens=input_tokens, output_tokens=output_tokens)
|
|
1815
|
+
cost_microdollars = _calculate_cost_microdollars(
|
|
1816
|
+
model,
|
|
1817
|
+
input_tokens,
|
|
1818
|
+
output_tokens,
|
|
1819
|
+
0,
|
|
1820
|
+
service_tier,
|
|
1821
|
+
)
|
|
1822
|
+
return (
|
|
1823
|
+
cost_microdollars
|
|
1824
|
+
if cost_microdollars > 0
|
|
1825
|
+
else _unknown_model_reserve_cost_budget_microdollars(input_tokens=input_tokens, output_tokens=output_tokens)
|
|
1826
|
+
)
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
def _unknown_model_reserve_cost_budget_microdollars(*, input_tokens: int, output_tokens: int) -> int:
|
|
1830
|
+
token_budget = max(0, input_tokens) + max(0, output_tokens)
|
|
1831
|
+
if token_budget <= 0:
|
|
1832
|
+
return 0
|
|
1833
|
+
return ceil(
|
|
1834
|
+
_API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_MICRODOLLARS
|
|
1835
|
+
* token_budget
|
|
1836
|
+
/ _API_KEY_USAGE_RESERVATION_UNKNOWN_MODEL_BASE_TOKENS
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _compute_increment_for_limit_type(
|
|
1841
|
+
limit_type: LimitType,
|
|
1842
|
+
*,
|
|
1843
|
+
input_tokens: int,
|
|
1844
|
+
output_tokens: int,
|
|
1845
|
+
cost_microdollars: int,
|
|
1846
|
+
) -> int:
|
|
1847
|
+
if limit_type == LimitType.TOTAL_TOKENS:
|
|
1848
|
+
return input_tokens + output_tokens
|
|
1849
|
+
if limit_type == LimitType.INPUT_TOKENS:
|
|
1850
|
+
return input_tokens
|
|
1851
|
+
if limit_type == LimitType.OUTPUT_TOKENS:
|
|
1852
|
+
return output_tokens
|
|
1853
|
+
if limit_type == LimitType.COST_USD:
|
|
1854
|
+
return cost_microdollars
|
|
1855
|
+
if limit_type == LimitType.CREDITS:
|
|
1856
|
+
return 0
|
|
1857
|
+
return 0
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
def _next_usage_reservation_id() -> str:
|
|
1861
|
+
return f"ur_{uuid.uuid4().hex}"
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def _to_created_data(data: ApiKeyData, key: str) -> ApiKeyCreatedData:
|
|
1865
|
+
return ApiKeyCreatedData(
|
|
1866
|
+
id=data.id,
|
|
1867
|
+
name=data.name,
|
|
1868
|
+
key_prefix=data.key_prefix,
|
|
1869
|
+
allowed_models=data.allowed_models,
|
|
1870
|
+
apply_to_codex_model=data.apply_to_codex_model,
|
|
1871
|
+
enforced_model=data.enforced_model,
|
|
1872
|
+
enforced_reasoning_effort=data.enforced_reasoning_effort,
|
|
1873
|
+
allowed_reasoning_efforts=data.allowed_reasoning_efforts,
|
|
1874
|
+
enforced_service_tier=data.enforced_service_tier,
|
|
1875
|
+
traffic_class=data.traffic_class,
|
|
1876
|
+
transport_policy_override=data.transport_policy_override,
|
|
1877
|
+
thread_cache_identity_override=data.thread_cache_identity_override,
|
|
1878
|
+
usage_sections=data.usage_sections,
|
|
1879
|
+
expires_at=data.expires_at,
|
|
1880
|
+
is_active=data.is_active,
|
|
1881
|
+
created_at=data.created_at,
|
|
1882
|
+
last_used_at=data.last_used_at,
|
|
1883
|
+
limits=data.limits,
|
|
1884
|
+
usage_summary=data.usage_summary,
|
|
1885
|
+
account_assignment_scope_enabled=data.account_assignment_scope_enabled,
|
|
1886
|
+
source_assignment_scope_enabled=data.source_assignment_scope_enabled,
|
|
1887
|
+
assigned_account_ids=data.assigned_account_ids,
|
|
1888
|
+
assigned_source_ids=data.assigned_source_ids,
|
|
1889
|
+
key=key,
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def _to_api_key_data(
|
|
1894
|
+
row: ApiKey,
|
|
1895
|
+
*,
|
|
1896
|
+
usage_summary: ApiKeyUsageSummaryData | None = None,
|
|
1897
|
+
pooled_credits: PooledCreditData | None = None,
|
|
1898
|
+
) -> ApiKeyData:
|
|
1899
|
+
limits = [_to_limit_rule_data(limit) for limit in row.limits] if row.limits else []
|
|
1900
|
+
account_assignments = getattr(row, "account_assignments", [])
|
|
1901
|
+
source_assignments = getattr(row, "source_assignments", [])
|
|
1902
|
+
return ApiKeyData(
|
|
1903
|
+
id=row.id,
|
|
1904
|
+
name=row.name,
|
|
1905
|
+
key_prefix=row.key_prefix,
|
|
1906
|
+
allowed_models=_deserialize_allowed_models(row.allowed_models),
|
|
1907
|
+
apply_to_codex_model=getattr(row, "apply_to_codex_model", False),
|
|
1908
|
+
enforced_model=_normalize_model_slug(row.enforced_model),
|
|
1909
|
+
enforced_reasoning_effort=_normalize_reasoning_effort_lenient(row.enforced_reasoning_effort),
|
|
1910
|
+
allowed_reasoning_efforts=_deserialize_allowed_reasoning_efforts(
|
|
1911
|
+
getattr(row, "allowed_reasoning_efforts", None)
|
|
1912
|
+
),
|
|
1913
|
+
enforced_service_tier=_normalize_service_tier_lenient(row.enforced_service_tier),
|
|
1914
|
+
traffic_class=_normalize_traffic_class_lenient(getattr(row, "traffic_class", TRAFFIC_CLASS_FOREGROUND)),
|
|
1915
|
+
transport_policy_override=_normalize_transport_policy_override_lenient(
|
|
1916
|
+
getattr(row, "transport_policy_override", None)
|
|
1917
|
+
),
|
|
1918
|
+
thread_cache_identity_override=_normalize_thread_cache_identity_override_lenient(
|
|
1919
|
+
getattr(row, "thread_cache_identity_override", None)
|
|
1920
|
+
),
|
|
1921
|
+
usage_sections=_get_usage_sections_with_default(row),
|
|
1922
|
+
expires_at=row.expires_at,
|
|
1923
|
+
is_active=row.is_active,
|
|
1924
|
+
created_at=row.created_at,
|
|
1925
|
+
last_used_at=row.last_used_at,
|
|
1926
|
+
limits=limits,
|
|
1927
|
+
usage_summary=usage_summary,
|
|
1928
|
+
account_assignment_scope_enabled=getattr(row, "account_assignment_scope_enabled", False),
|
|
1929
|
+
source_assignment_scope_enabled=getattr(row, "source_assignment_scope_enabled", False),
|
|
1930
|
+
assigned_account_ids=[assignment.account_id for assignment in account_assignments],
|
|
1931
|
+
assigned_source_ids=[assignment.source_id for assignment in source_assignments],
|
|
1932
|
+
pooled_credits=pooled_credits,
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
|
|
1936
|
+
def _to_usage_summary_data(summary: ApiKeyUsageSummary | None) -> ApiKeyUsageSummaryData | None:
|
|
1937
|
+
if summary is None:
|
|
1938
|
+
return None
|
|
1939
|
+
return ApiKeyUsageSummaryData(
|
|
1940
|
+
request_count=summary.request_count,
|
|
1941
|
+
total_tokens=summary.total_tokens,
|
|
1942
|
+
cached_input_tokens=summary.cached_input_tokens,
|
|
1943
|
+
total_cost_usd=summary.total_cost_usd,
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _limit_input_to_row(
|
|
1948
|
+
li: LimitRuleInput,
|
|
1949
|
+
key_id: str,
|
|
1950
|
+
now: datetime,
|
|
1951
|
+
*,
|
|
1952
|
+
current_value: int = 0,
|
|
1953
|
+
reset_at: datetime | None = None,
|
|
1954
|
+
) -> ApiKeyLimit:
|
|
1955
|
+
window = LimitWindow(li.limit_window)
|
|
1956
|
+
if li.limit_type == LimitType.CREDITS.value and li.model_filter is not None:
|
|
1957
|
+
raise ApiKeyValidationError("credits limits do not support model_filter")
|
|
1958
|
+
return ApiKeyLimit(
|
|
1959
|
+
api_key_id=key_id,
|
|
1960
|
+
limit_type=LimitType(li.limit_type),
|
|
1961
|
+
limit_window=window,
|
|
1962
|
+
max_value=li.max_value,
|
|
1963
|
+
current_value=current_value,
|
|
1964
|
+
model_filter=li.model_filter,
|
|
1965
|
+
reset_at=reset_at if reset_at is not None else next_limit_reset(now, window),
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
async def _build_limit_rows_for_update(
|
|
1970
|
+
*,
|
|
1971
|
+
key_id: str,
|
|
1972
|
+
now: datetime,
|
|
1973
|
+
submitted_limits: list[LimitRuleInput],
|
|
1974
|
+
existing_limits: list[ApiKeyLimit],
|
|
1975
|
+
reset_usage: bool,
|
|
1976
|
+
repository: ApiKeysRepositoryProtocol | None = None,
|
|
1977
|
+
) -> list[ApiKeyLimit]:
|
|
1978
|
+
existing_by_key = {_limit_identity_from_row(limit): limit for limit in existing_limits}
|
|
1979
|
+
_validate_unique_limit_rule_identities(submitted_limits)
|
|
1980
|
+
|
|
1981
|
+
rows: list[ApiKeyLimit] = []
|
|
1982
|
+
for submitted in submitted_limits:
|
|
1983
|
+
identity = _limit_identity_from_input(submitted)
|
|
1984
|
+
matched = existing_by_key.get(identity)
|
|
1985
|
+
if reset_usage:
|
|
1986
|
+
rows.append(_limit_input_to_row(submitted, key_id, now))
|
|
1987
|
+
continue
|
|
1988
|
+
if matched is None:
|
|
1989
|
+
if repository is None:
|
|
1990
|
+
raise TypeError("repository is required to backfill new API key limit usage")
|
|
1991
|
+
rows.append(await _new_limit_input_to_backfilled_row(submitted, key_id, now, repository))
|
|
1992
|
+
continue
|
|
1993
|
+
rows.append(
|
|
1994
|
+
_limit_input_to_row(
|
|
1995
|
+
submitted,
|
|
1996
|
+
key_id,
|
|
1997
|
+
now,
|
|
1998
|
+
current_value=matched.current_value,
|
|
1999
|
+
reset_at=matched.reset_at,
|
|
2000
|
+
)
|
|
2001
|
+
)
|
|
2002
|
+
return rows
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
async def _new_limit_input_to_backfilled_row(
|
|
2006
|
+
submitted: LimitRuleInput,
|
|
2007
|
+
key_id: str,
|
|
2008
|
+
now: datetime,
|
|
2009
|
+
repository: ApiKeysRepositoryProtocol,
|
|
2010
|
+
) -> ApiKeyLimit:
|
|
2011
|
+
limit_type = LimitType(submitted.limit_type)
|
|
2012
|
+
window = LimitWindow(submitted.limit_window)
|
|
2013
|
+
reset_at = next_limit_reset(now, window)
|
|
2014
|
+
since = now - limit_window_delta(window)
|
|
2015
|
+
current_value = await repository.get_limit_usage_value(
|
|
2016
|
+
key_id,
|
|
2017
|
+
limit_type=limit_type,
|
|
2018
|
+
since=since,
|
|
2019
|
+
until=now,
|
|
2020
|
+
model_filter=submitted.model_filter,
|
|
2021
|
+
)
|
|
2022
|
+
return _limit_input_to_row(
|
|
2023
|
+
submitted,
|
|
2024
|
+
key_id,
|
|
2025
|
+
now,
|
|
2026
|
+
current_value=current_value,
|
|
2027
|
+
reset_at=reset_at,
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def _build_reset_limit_rows(
|
|
2032
|
+
*,
|
|
2033
|
+
key_id: str,
|
|
2034
|
+
now: datetime,
|
|
2035
|
+
existing_limits: list[ApiKeyLimit],
|
|
2036
|
+
) -> list[ApiKeyLimit]:
|
|
2037
|
+
rows: list[ApiKeyLimit] = []
|
|
2038
|
+
for existing in existing_limits:
|
|
2039
|
+
rows.append(
|
|
2040
|
+
ApiKeyLimit(
|
|
2041
|
+
api_key_id=key_id,
|
|
2042
|
+
limit_type=existing.limit_type,
|
|
2043
|
+
limit_window=existing.limit_window,
|
|
2044
|
+
max_value=existing.max_value,
|
|
2045
|
+
current_value=0,
|
|
2046
|
+
model_filter=existing.model_filter,
|
|
2047
|
+
reset_at=next_limit_reset(now, existing.limit_window),
|
|
2048
|
+
)
|
|
2049
|
+
)
|
|
2050
|
+
return rows
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def _limit_identity_from_input(limit: LimitRuleInput) -> tuple[str, str, str | None]:
|
|
2054
|
+
return (limit.limit_type, limit.limit_window, limit.model_filter)
|
|
2055
|
+
|
|
2056
|
+
|
|
2057
|
+
def _validate_unique_limit_rule_identities(limits: list[LimitRuleInput]) -> None:
|
|
2058
|
+
identities: set[tuple[str, str, str | None]] = set()
|
|
2059
|
+
for limit in limits:
|
|
2060
|
+
identity = _limit_identity_from_input(limit)
|
|
2061
|
+
if identity in identities:
|
|
2062
|
+
raise ApiKeyValidationError(f"Duplicate limit rules are not allowed: {identity!r}")
|
|
2063
|
+
identities.add(identity)
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
def _limit_identity_from_row(limit: ApiKeyLimit) -> tuple[str, str, str | None]:
|
|
2067
|
+
return (limit.limit_type.value, limit.limit_window.value, limit.model_filter)
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def _calculate_cost_microdollars(
|
|
2071
|
+
model: str,
|
|
2072
|
+
input_tokens: int,
|
|
2073
|
+
output_tokens: int,
|
|
2074
|
+
cached_input_tokens: int,
|
|
2075
|
+
service_tier: str | None = None,
|
|
2076
|
+
) -> int:
|
|
2077
|
+
resolved = get_pricing_for_model(model)
|
|
2078
|
+
if resolved is None:
|
|
2079
|
+
return 0
|
|
2080
|
+
_, price = resolved
|
|
2081
|
+
usage = UsageTokens(
|
|
2082
|
+
input_tokens=float(input_tokens),
|
|
2083
|
+
output_tokens=float(output_tokens),
|
|
2084
|
+
cached_input_tokens=float(cached_input_tokens),
|
|
2085
|
+
)
|
|
2086
|
+
cost_usd = calculate_cost_from_usage(usage, price, service_tier=service_tier)
|
|
2087
|
+
if cost_usd is None:
|
|
2088
|
+
return 0
|
|
2089
|
+
return int(cost_usd * 1_000_000)
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
def _build_api_key_trends(
|
|
2093
|
+
key_id: str,
|
|
2094
|
+
buckets: list[ApiKeyTrendBucket],
|
|
2095
|
+
since: datetime,
|
|
2096
|
+
until: datetime,
|
|
2097
|
+
bucket_seconds: int,
|
|
2098
|
+
) -> ApiKeyTrendsData:
|
|
2099
|
+
since_utc = since.replace(tzinfo=timezone.utc) if since.tzinfo is None else since.astimezone(timezone.utc)
|
|
2100
|
+
until_utc = until.replace(tzinfo=timezone.utc) if until.tzinfo is None else until.astimezone(timezone.utc)
|
|
2101
|
+
if until_utc <= since_utc:
|
|
2102
|
+
return ApiKeyTrendsData(key_id=key_id)
|
|
2103
|
+
|
|
2104
|
+
start_epoch = (int(since_utc.timestamp()) // bucket_seconds) * bucket_seconds
|
|
2105
|
+
# The SQL window is exclusive of `until`, so step back one microsecond to find the last visible bucket.
|
|
2106
|
+
end_epoch = (int((until_utc - timedelta(microseconds=1)).timestamp()) // bucket_seconds) * bucket_seconds
|
|
2107
|
+
bucket_count = ((end_epoch - start_epoch) // bucket_seconds) + 1
|
|
2108
|
+
time_grid = [start_epoch + i * bucket_seconds for i in range(bucket_count)]
|
|
2109
|
+
|
|
2110
|
+
cost_by_bucket: dict[int, float] = {}
|
|
2111
|
+
tokens_by_bucket: dict[int, int] = {}
|
|
2112
|
+
for b in buckets:
|
|
2113
|
+
cost_by_bucket[b.bucket_epoch] = b.total_cost_usd
|
|
2114
|
+
tokens_by_bucket[b.bucket_epoch] = b.total_tokens
|
|
2115
|
+
|
|
2116
|
+
cost_points: list[ApiKeyTrendsPoint] = []
|
|
2117
|
+
tokens_points: list[ApiKeyTrendsPoint] = []
|
|
2118
|
+
for epoch in time_grid:
|
|
2119
|
+
dt = datetime.fromtimestamp(epoch, tz=timezone.utc)
|
|
2120
|
+
cost_points.append(ApiKeyTrendsPoint(t=dt, v=round(cost_by_bucket.get(epoch, 0.0), 6)))
|
|
2121
|
+
tokens_points.append(ApiKeyTrendsPoint(t=dt, v=float(tokens_by_bucket.get(epoch, 0))))
|
|
2122
|
+
|
|
2123
|
+
return ApiKeyTrendsData(key_id=key_id, cost=cost_points, tokens=tokens_points)
|