diff --git a/Cargo.lock b/Cargo.lock index d83fc53c1..ab6a7e347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,7 @@ dependencies = [ "axum", "base64", "chrono", + "chrono-tz", "http", "regex", "reqwest 0.12.28", @@ -218,6 +219,7 @@ dependencies = [ "chrono-tz", "flate2", "futures-util", + "serde", "serde_json", "sha2", "sqlx", diff --git a/README.md b/README.md index 6aa33eb10..3ad697e89 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙 - `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}` - `DATABASE_URL`:PostgreSQL 连接串,例如 `postgresql://USER:PASSWORD@HOST:5432/aether` - `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时 PostgreSQL 按每核 `4` 条自动推导,总池范围为 `32-100`。该预算按进程计算,多实例部署应按数据库连接上限显式分配 -- `AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_TIMEOUT_MS` / `AETHER_GATEWAY_DATA_POSTGRES_LOCK_TIMEOUT_MS`:普通数据库连接的单条 SQL / 锁等待期限,默认 `30000` / `3000` 毫秒,显式 `0` 关闭;不是整个事务总期限。迁移与历史 backfill 使用独立连接放宽,事务可通过局部设置覆盖 +- `AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_TIMEOUT_MS` / `AETHER_GATEWAY_DATA_POSTGRES_LOCK_TIMEOUT_MS`:普通数据库连接的单条 SQL / 锁等待期限,默认 `30000` / `3000` 毫秒,显式 `0` 关闭;不是整个事务总期限。schema 迁移使用独立超时配置,历史 backfill 使用独立连接放宽期限 +- `AETHER_POSTGRES_MIGRATION_LOCK_TIMEOUT_MS` / `AETHER_POSTGRES_MIGRATION_TIMEOUT_MS` / `AETHER_POSTGRES_MIGRATION_CONCURRENT_TIMEOUT_MS`:schema 迁移的锁等待、每个事务及并发索引迁移期限,默认 `1000` / `10000` / `900000` 毫秒,不接受 `0`。超时会中止当前迁移,已提交的迁移保留;空库 schema 初始化也受事务期限约束 - `AETHER_USAGE_EVENT_CAPTURE_MEMORY_BUDGET_BYTES`:usage 诊断正文共享预算,默认 `134217728`(128 MiB),按 JSON 堆内存估算,覆盖进入终态队列的 seed、Redis 解码后的事件、数据库写入 DTO 及其正文副本。额度不足或显式 `0` 时先保留计费事实,再舍弃诊断正文;已有清空或禁用状态保持不变,其余标记截断。预算随正文保留到释放,后台构建或压缩不会因调用方取消而提前归还额度。该额度不覆盖原始 Redis 批次、解码临时分配、序列化及压缩结果、协议观察缓冲或进程总内存;可通过 `usage_runtime_event_capture_memory_*` 指标观察 - `AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES`:新增 usage 队列消息的完整 JSON payload 上限,默认 `1048576`(1 MiB),按序列化后的 UTF-8 字节计算,显式 `0` 非法。超限先保留计费事实并舍弃诊断字段;仍超限或无法保留计费语义时拒绝入队,终态消息尝试受限数据库落库,失败则明确失败,不继续 Redis 重试。该限制不覆盖存量 Redis 消息、整个读取批次、DLQ 或进程总内存。`usage_runtime_queue_payload_*` 导出上限及进程级降级、拒绝编码尝试次数,包含入队和重试预校验,不代表唯一事件数;`usage_runtime_enqueue_retry_permanent_failure_total` 记录永久输入错误导致的重试拒绝或终止 - `AETHER_USAGE_QUEUE_READ_PAYLOAD_BUDGET_BYTES` / `AETHER_USAGE_QUEUE_READ_BATCH_PAYLOAD_BYTES`:usage worker 读取和重领共用的进程级逻辑 payload 预留,默认总额 `134217728`(128 MiB)、单批目标 `8388608`(8 MiB)。按当前 `QUEUE_PAYLOAD_MAX_BYTES` 推导实际 COUNT,默认最多读取 8 条,自动扩容使用实际 COUNT 判断批次是否读满。预留覆盖读取、整批处理和确认,额度不足等待;取消/失败释放。单批目标至少允许一条,当前 payload 上限大于总额时读取报配置错误。`0` 或非法值回退默认,过大值收敛到约 4 GiB 的有效总额。收到消息后按全部字段值长度缩减多余预留;历史消息、其他生产者使用更高上限或额外字段可能超出估算,仍继续原计费流程并记录 `usage_runtime_queue_read_oversized_*`。`usage_runtime_queue_read_*` 同时导出预留、等待与累计字段字节;该预留不是 RESP 解码、连接缓冲容量、字段结构、诊断 JSON、DLQ 或进程 RSS 的硬上限,旧公开 Vec 读取接口不携带处理阶段预留 diff --git a/apps/aether-gateway/src/cache/mod.rs b/apps/aether-gateway/src/cache/mod.rs index b4ce72f1a..eb1123acb 100644 --- a/apps/aether-gateway/src/cache/mod.rs +++ b/apps/aether-gateway/src/cache/mod.rs @@ -4,6 +4,7 @@ mod auth_runtime; mod candidate_page; mod dashboard_response; mod direct_plan_bypass; +mod overview_total; mod scheduler_affinity; mod system_config; @@ -30,6 +31,7 @@ pub(crate) use candidate_page::{ }; pub(crate) use dashboard_response::DashboardResponseCache; pub(crate) use direct_plan_bypass::DirectPlanBypassCache; +pub(crate) use overview_total::{OverviewTotalCache, OverviewTotalRead}; pub(crate) use scheduler_affinity::{ SchedulerAffinityCache, SchedulerAffinitySnapshotEntry, SchedulerAffinityTarget, }; diff --git a/apps/aether-gateway/src/cache/overview_total.rs b/apps/aether-gateway/src/cache/overview_total.rs new file mode 100644 index 000000000..05a7e2bdd --- /dev/null +++ b/apps/aether-gateway/src/cache/overview_total.rs @@ -0,0 +1,196 @@ +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use aether_data_contracts::repository::usage::StoredUsageDashboardAnalytics; + +const FRESH_FOR: Duration = Duration::from_secs(5 * 60); +const FAILURE_BACKOFF: Duration = Duration::from_secs(10); + +#[derive(Debug, Default)] +pub(crate) struct OverviewTotalCache { + state: Mutex, +} + +#[derive(Debug, Default)] +struct CacheState { + value: Option<(Instant, Arc)>, + refreshing: bool, + retry_after: Option, +} + +pub(crate) enum OverviewTotalRead { + Pending, + Failed, + Ready { + snapshot: Arc, + stale: bool, + }, +} + +/// Owns the single refresh slot even if the request that launched it disconnects. +/// Dropping a cancelled or panicking worker also releases the slot with backoff. +pub(crate) struct OverviewTotalRefresh { + cache: Arc, + completed: bool, +} + +impl OverviewTotalCache { + pub(crate) fn read( + self: &Arc, + now: Instant, + ) -> (OverviewTotalRead, Option) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + let fresh = state + .value + .as_ref() + .is_some_and(|(at, _)| now.saturating_duration_since(*at) < FRESH_FOR); + let retry_allowed = state.retry_after.is_none_or(|after| now >= after); + let refresh = if !fresh && !state.refreshing && retry_allowed { + state.refreshing = true; + Some(OverviewTotalRefresh { + cache: Arc::clone(self), + completed: false, + }) + } else { + None + }; + let result = match &state.value { + Some((_, snapshot)) => OverviewTotalRead::Ready { + snapshot: Arc::clone(snapshot), + stale: !fresh, + }, + None if state.refreshing => OverviewTotalRead::Pending, + None => OverviewTotalRead::Failed, + }; + (result, refresh) + } +} + +impl OverviewTotalRefresh { + pub(crate) fn finish(mut self, snapshot: Option, now: Instant) { + let mut state = self + .cache + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.refreshing = false; + if let Some(snapshot) = snapshot { + state.value = Some((now, Arc::new(snapshot))); + state.retry_after = None; + } else { + state.retry_after = Some(now + FAILURE_BACKOFF); + } + self.completed = true; + } +} + +impl Drop for OverviewTotalRefresh { + fn drop(&mut self) { + if !self.completed { + let mut state = self + .cache + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.refreshing = false; + state.retry_after = Some(Instant::now() + FAILURE_BACKOFF); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot() -> StoredUsageDashboardAnalytics { + let mut snapshot = StoredUsageDashboardAnalytics::default(); + snapshot.total.generated_at = "2026-09-18T00:00:00Z".into(); + snapshot.total.read_revision = "revision-1".into(); + snapshot.total.summary.request_count = 42; + snapshot + } + + #[test] + fn concurrent_cold_reads_claim_one_refresh() { + let cache = Arc::new(OverviewTotalCache::default()); + let barrier = Arc::new(std::sync::Barrier::new(16)); + let now = Instant::now(); + let workers = (0..16) + .map(|_| { + let cache = Arc::clone(&cache); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + let (read, refresh) = cache.read(now); + assert!(matches!(read, OverviewTotalRead::Pending)); + refresh + }) + }) + .collect::>(); + let mut refreshes = workers + .into_iter() + .filter_map(|worker| worker.join().unwrap()) + .collect::>(); + assert_eq!(refreshes.len(), 1); + refreshes.pop().unwrap().finish(Some(snapshot()), now); + let (read, refresh) = cache.read(now); + assert!(matches!( + read, + OverviewTotalRead::Ready { stale: false, .. } + )); + assert!(refresh.is_none()); + } + + #[test] + fn expiration_returns_original_snapshot_and_failed_refresh_preserves_it() { + let cache = Arc::new(OverviewTotalCache::default()); + let now = Instant::now(); + cache.read(now).1.unwrap().finish(Some(snapshot()), now); + assert!(cache + .read(now + FRESH_FOR - Duration::from_secs(1)) + .1 + .is_none()); + let expired = now + FRESH_FOR; + let (read, refresh) = cache.read(expired); + let OverviewTotalRead::Ready { + snapshot: old, + stale: true, + } = read + else { + panic!("expired success must remain visible") + }; + assert_eq!(old.total.generated_at, "2026-09-18T00:00:00Z"); + assert_eq!(old.total.read_revision, "revision-1"); + assert!(cache.read(expired).1.is_none()); + refresh.unwrap().finish(None, expired); + let (read, retry) = cache.read(expired + FAILURE_BACKOFF - Duration::from_secs(1)); + let OverviewTotalRead::Ready { + snapshot: retained, + stale: true, + } = read + else { + panic!("failed refresh must retain stale success") + }; + assert!(Arc::ptr_eq(&old, &retained)); + assert!(retry.is_none()); + assert!(cache.read(expired + FAILURE_BACKOFF).1.is_some()); + } + + #[test] + fn cold_failure_and_worker_cancellation_back_off_before_retrying() { + let cache = Arc::new(OverviewTotalCache::default()); + let now = Instant::now(); + cache.read(now).1.unwrap().finish(None, now); + let (read, refresh) = cache.read(now + Duration::from_secs(9)); + assert!(matches!(read, OverviewTotalRead::Failed)); + assert!(refresh.is_none()); + let (read, refresh) = cache.read(now + FAILURE_BACKOFF); + assert!(matches!(read, OverviewTotalRead::Pending)); + drop(refresh); + let after_cancel = Instant::now(); + let (read, refresh) = cache.read(after_cancel); + assert!(matches!(read, OverviewTotalRead::Failed)); + assert!(refresh.is_none()); + assert!(cache.read(after_cancel + FAILURE_BACKOFF).1.is_some()); + } +} diff --git a/apps/aether-gateway/src/control/route/admin/basic_families.rs b/apps/aether-gateway/src/control/route/admin/basic_families.rs index 1f70d7dd5..55a817de0 100644 --- a/apps/aether-gateway/src/control/route/admin/basic_families.rs +++ b/apps/aether-gateway/src/control/route/admin/basic_families.rs @@ -7,6 +7,35 @@ pub(super) fn classify_admin_basic_family_route( normalized_path: &str, normalized_path_no_trailing: &str, ) -> Option { + let finance_path = normalized_path_no_trailing; + if (method == http::Method::GET + && matches!( + finance_path, + "/api/admin/billing/provider-accounts" | "/api/admin/billing/provider-expenses" + )) + || (method == http::Method::POST && finance_path == "/api/admin/billing/provider-expenses") + || (method == http::Method::POST + && finance_path + .strip_prefix("/api/admin/billing/provider-expenses/") + .and_then(|v| v.strip_suffix("/void")) + .is_some_and(|id| !id.is_empty() && !id.contains('/'))) + { + return Some(classified( + "admin_proxy", + "billing_manage", + if finance_path.ends_with("/provider-accounts") { + "provider_accounts" + } else if method == http::Method::GET { + "provider_expenses" + } else if finance_path.ends_with("/void") { + "void_provider_expense" + } else { + "create_provider_expense" + }, + "admin:billing", + false, + )); + } if method == http::Method::GET && matches!( normalized_path, diff --git a/apps/aether-gateway/src/control/route/admin/endpoints_families.rs b/apps/aether-gateway/src/control/route/admin/endpoints_families.rs index 86899388d..05d483346 100644 --- a/apps/aether-gateway/src/control/route/admin/endpoints_families.rs +++ b/apps/aether-gateway/src/control/route/admin/endpoints_families.rs @@ -6,6 +6,33 @@ pub(super) fn classify_admin_endpoints_family_route( method: &http::Method, normalized_path: &str, ) -> Option { + if normalized_path == "/api/admin/endpoints/health/v2/publication" + && (method == http::Method::GET || method == http::Method::PUT) + { + return Some(classified( + "admin_proxy", + "endpoints_health", + "health_v2_publication", + "admin:endpoints_health", + false, + )); + } + if method == http::Method::GET + && (matches!( + normalized_path, + "/api/admin/endpoints/health/v2/summary" | "/api/admin/endpoints/health/v2/objects" + ) || normalized_path + .strip_prefix("/api/admin/endpoints/health/v2/objects/") + .is_some_and(|id| !id.is_empty() && !id.contains('/'))) + { + return Some(classified( + "admin_proxy", + "endpoints_health", + "health_v2", + "admin:endpoints_health", + false, + )); + } if method == http::Method::GET && normalized_path == "/api/admin/endpoints/health/summary" { Some(classified( "admin_proxy", diff --git a/apps/aether-gateway/src/control/route/admin/observability_families.rs b/apps/aether-gateway/src/control/route/admin/observability_families.rs index 54eea3921..3c287781a 100644 --- a/apps/aether-gateway/src/control/route/admin/observability_families.rs +++ b/apps/aether-gateway/src/control/route/admin/observability_families.rs @@ -7,6 +7,15 @@ pub(super) fn classify_admin_observability_family_route( normalized_path: &str, normalized_path_no_trailing: &str, ) -> Option { + if let Some(kind) = classify_overview_route(method, normalized_path_no_trailing) { + return Some(classified( + "admin_proxy", + "overview_manage", + kind, + "admin:stats", + false, + )); + } if method == http::Method::POST && matches!( normalized_path, @@ -713,3 +722,32 @@ pub(super) fn classify_admin_observability_family_route( None } } + +fn classify_overview_route(method: &http::Method, path: &str) -> Option<&'static str> { + if method != http::Method::GET { + return None; + } + match path.strip_prefix("/api/admin/overview/")? { + "dashboard" => Some("dashboard"), + "dashboard/summary" => Some("dashboard_summary"), + "dashboard/total" => Some("dashboard_total"), + "dashboard/charts" => Some("dashboard_charts"), + "summary" => Some("summary"), + "timeseries" => Some("timeseries"), + "breakdown" => Some("breakdown"), + "users" => Some("users"), + "consumption" => Some("consumption"), + "costs" => Some("costs"), + "operations/live" => Some("operations_live"), + "operations/performance" => Some("operations_performance"), + "operations/resources" => Some("operations_resources"), + detail + if detail + .strip_prefix("users/") + .is_some_and(|id| !id.is_empty() && !id.contains('/')) => + { + Some("user_detail") + } + _ => None, + } +} diff --git a/apps/aether-gateway/src/control/route/public_support.rs b/apps/aether-gateway/src/control/route/public_support.rs index 77e662919..2ada334de 100644 --- a/apps/aether-gateway/src/control/route/public_support.rs +++ b/apps/aether-gateway/src/control/route/public_support.rs @@ -146,6 +146,36 @@ pub(super) fn classify_public_support_route( "public:announcements", false, )) + } else if method == http::Method::GET + && (matches!( + normalized_path, + "/api/users/me/health/v2/summary" | "/api/users/me/health/v2/objects" + ) || normalized_path + .strip_prefix("/api/users/me/health/v2/objects/") + .is_some_and(|id| !id.is_empty() && !id.contains('/'))) + { + Some(classified( + "public_support", + "health_user", + "health_v2", + "user:health", + false, + )) + } else if method == http::Method::GET + && (matches!( + normalized_path, + "/api/public/health/v2/summary" | "/api/public/health/v2/objects" + ) || normalized_path + .strip_prefix("/api/public/health/v2/objects/") + .is_some_and(|id| !id.is_empty() && !id.contains('/'))) + { + Some(classified( + "public_support", + "public_catalog", + "health_v2", + "public:catalog", + false, + )) } else if method == http::Method::GET && matches!( normalized_path, @@ -273,6 +303,19 @@ pub(super) fn classify_public_support_route( "user:monitoring", false, )) + } else if method == http::Method::GET + && matches!( + normalized_path, + "/api/announcements/users/me" | "/api/announcements/users/me/" + ) + { + Some(classified( + "public_support", + "announcement_user", + "list", + "user:announcements", + false, + )) } else if method == http::Method::GET && matches!( normalized_path, diff --git a/apps/aether-gateway/src/control/tests/admin_billing.rs b/apps/aether-gateway/src/control/tests/admin_billing.rs index 58c5228b5..652dda76b 100644 --- a/apps/aether-gateway/src/control/tests/admin_billing.rs +++ b/apps/aether-gateway/src/control/tests/admin_billing.rs @@ -203,3 +203,50 @@ fn admin_billing_plan_write_routes_buffer_request_body() { ); } } + +#[test] +fn provider_finance_routes_require_admin_billing_and_buffer_expense_input() { + let headers = headers(&[]); + for (method, path, kind) in [ + ( + http::Method::GET, + "/api/admin/billing/provider-accounts", + "provider_accounts", + ), + ( + http::Method::GET, + "/api/admin/billing/provider-expenses", + "provider_expenses", + ), + ( + http::Method::POST, + "/api/admin/billing/provider-expenses", + "create_provider_expense", + ), + ( + http::Method::POST, + "/api/admin/billing/provider-expenses/entry-1/void", + "void_provider_expense", + ), + ] { + let uri: Uri = path.parse().unwrap(); + let decision = classify_control_route(&method, &uri, &headers).unwrap(); + assert_eq!(decision.route_family.as_deref(), Some("billing_manage")); + assert_eq!(decision.route_kind.as_deref(), Some(kind)); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("admin:billing") + ); + let context = GatewayPublicRequestContext::from_request_parts( + "expense-test", + &method, + &uri, + &headers, + Some(decision), + ); + assert_eq!( + local_proxy_route_requires_buffered_body(&context), + kind == "create_provider_expense" + ); + } +} diff --git a/apps/aether-gateway/src/control/tests/admin_stats.rs b/apps/aether-gateway/src/control/tests/admin_stats.rs index 63c78530c..7f12d4b62 100644 --- a/apps/aether-gateway/src/control/tests/admin_stats.rs +++ b/apps/aether-gateway/src/control/tests/admin_stats.rs @@ -2,6 +2,46 @@ use http::Uri; use super::{classify_control_route, headers}; +#[test] +fn overview_routes_require_the_admin_stats_principal_and_get_method() { + for (suffix, kind) in [ + ("dashboard", "dashboard"), + ("dashboard/summary", "dashboard_summary"), + ("dashboard/total", "dashboard_total"), + ("dashboard/charts", "dashboard_charts"), + ("summary", "summary"), + ("timeseries", "timeseries"), + ("breakdown", "breakdown"), + ("users", "users"), + ("users/employee-1", "user_detail"), + ("consumption", "consumption"), + ("costs", "costs"), + ("operations/live", "operations_live"), + ("operations/performance", "operations_performance"), + ("operations/resources", "operations_resources"), + ] { + for trailing in ["", "/"] { + let uri: Uri = format!("/api/admin/overview/{suffix}{trailing}") + .parse() + .unwrap(); + let decision = classify_control_route(&http::Method::GET, &uri, &headers(&[])).unwrap(); + assert_eq!(decision.route_family.as_deref(), Some("overview_manage")); + assert_eq!(decision.route_kind.as_deref(), Some(kind)); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("admin:stats") + ); + assert!(!decision.is_execution_runtime_candidate()); + let decision = classify_control_route(&http::Method::POST, &uri, &headers(&[])); + assert!( + decision.is_none_or( + |decision| decision.route_family.as_deref() != Some("overview_manage") + ) + ); + } + } +} + #[test] fn classifies_admin_stats_provider_quota_usage_as_admin_proxy_route() { let headers = headers(&[]); diff --git a/apps/aether-gateway/src/control/tests/public_support.rs b/apps/aether-gateway/src/control/tests/public_support.rs index a5c1efa19..b758c658a 100644 --- a/apps/aether-gateway/src/control/tests/public_support.rs +++ b/apps/aether-gateway/src/control/tests/public_support.rs @@ -261,6 +261,27 @@ fn classifies_wallet_redeem_as_public_support_route() { ); } +#[test] +fn classifies_personal_announcements_as_authenticated_user_route() { + let headers = headers(&[]); + for path in [ + "/api/announcements/users/me?limit=20&offset=0&unread_only=false", + "/api/announcements/users/me/", + ] { + let uri: Uri = path.parse().expect("uri should parse"); + let decision = classify_control_route(&http::Method::GET, &uri, &headers) + .expect("route should classify"); + assert_eq!(decision.route_class.as_deref(), Some("public_support")); + assert_eq!(decision.route_family.as_deref(), Some("announcement_user")); + assert_eq!(decision.route_kind.as_deref(), Some("list")); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("user:announcements") + ); + assert!(!decision.is_execution_runtime_candidate()); + } +} + #[test] fn classifies_announcement_unread_count_as_public_support_route() { let headers = headers(&[]); diff --git a/apps/aether-gateway/src/data/state/auth.rs b/apps/aether-gateway/src/data/state/auth.rs index 69280d5aa..572171734 100644 --- a/apps/aether-gateway/src/data/state/auth.rs +++ b/apps/aether-gateway/src/data/state/auth.rs @@ -1252,6 +1252,7 @@ impl GatewayDataState { // exists while avoiding an unbounded read during error compensation. let page = repository .list_admin_wallets(&aether_data::repository::wallet::AdminWalletListQuery { + user_id: None, status: None, owner_type: Some("api_key".to_string()), limit: 1, diff --git a/apps/aether-gateway/src/data/state/runtime.rs b/apps/aether-gateway/src/data/state/runtime.rs index 901563379..c11ac96d2 100644 --- a/apps/aether-gateway/src/data/state/runtime.rs +++ b/apps/aether-gateway/src/data/state/runtime.rs @@ -41,6 +41,9 @@ use super::{ VideoTaskStatusCount, WalletDailyUsageAggregationInput, WalletDailyUsageAggregationResult, WalletLookupKey, WalletMutationOutcome, }; +use aether_data_contracts::repository::billing::{ + ProviderExpenseInput, ProviderExpensePage, ProviderExpenseQuery, ProviderExpenseRecord, +}; use aether_data_contracts::repository::usage::{ PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest, StoredProviderApiKeyWindowUsageSummary, StoredUsageDailySummary, UsageAuditListQuery, @@ -364,6 +367,26 @@ impl GatewayDataState { } } + pub(crate) async fn rebuild_overview_buckets( + &self, + input: &aether_data::StatsHourlyAggregationInput, + ) -> Result { + match &self.backends { + Some(backends) => backends.rebuild_overview_buckets(input).await, + None => Ok(0), + } + } + + pub(crate) async fn drain_overview_dirty_events( + &self, + now: chrono::DateTime, + ) -> Result { + match &self.backends { + Some(backends) => backends.drain_overview_dirty_events(now).await, + None => Ok(0), + } + } + pub(crate) async fn aggregate_stats_daily( &self, input: &aether_data::StatsDailyAggregationInput, @@ -384,6 +407,18 @@ impl GatewayDataState { } } + pub(crate) async fn list_user_announcements( + &self, + user_id: &str, + query: &aether_data::repository::announcements::UserAnnouncementListQuery, + ) -> Result + { + match &self.announcement_reader { + Some(repository) => repository.list_user_announcements(user_id, query).await, + None => Ok(Default::default()), + } + } + pub(crate) async fn find_announcement_by_id( &self, announcement_id: &str, @@ -1659,6 +1694,60 @@ impl GatewayDataState { } } + pub(crate) async fn query_dashboard_summary( + &self, + query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery, + ) -> Result + { + match &self.usage_reader { + Some(repository) => repository.query_dashboard_summary(query).await, + None => Err(DataLayerError::InvalidInput( + "dashboard summary repository is unavailable".into(), + )), + } + } + + pub(crate) async fn query_dashboard_analytics( + &self, + query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery, + ) -> Result< + aether_data_contracts::repository::usage::StoredUsageDashboardAnalytics, + DataLayerError, + > { + match &self.usage_reader { + Some(repository) => repository.query_dashboard_analytics(query).await, + None => Err(DataLayerError::InvalidInput( + "usage analytics repository is unavailable".into(), + )), + } + } + + pub(crate) async fn query_usage_analytics( + &self, + query: &aether_data_contracts::repository::usage::UsageAnalyticsQuery, + ) -> Result + { + match &self.usage_reader { + Some(repository) => repository.query_usage_analytics(query).await, + None => Err(DataLayerError::InvalidInput( + "usage analytics repository is unavailable".into(), + )), + } + } + + pub(crate) async fn summarize_health_observations( + &self, + query: &aether_data_contracts::repository::usage::HealthObservationQuery, + ) -> Result + { + match &self.usage_reader { + Some(repository) => repository.summarize_health_observations(query).await, + None => Err(DataLayerError::InvalidInput( + "health observations repository is unavailable".into(), + )), + } + } + pub(crate) async fn summarize_usage_audits( &self, query: &aether_data_contracts::repository::usage::UsageAuditSummaryQuery, @@ -2747,6 +2836,35 @@ impl GatewayDataState { } } + pub(crate) async fn list_provider_expenses( + &self, + query: &ProviderExpenseQuery, + ) -> Result, DataLayerError> { + match &self.billing_reader { + Some(repo) => repo.list_provider_expenses(query).await, + None => Ok(None), + } + } + pub(crate) async fn create_provider_expense( + &self, + input: &ProviderExpenseInput, + ) -> Result, DataLayerError> { + match &self.billing_reader { + Some(repo) => repo.create_provider_expense(input).await, + None => Ok(AdminBillingMutationOutcome::Unavailable), + } + } + pub(crate) async fn void_provider_expense( + &self, + id: &str, + operator: Option<&str>, + ) -> Result, DataLayerError> { + match &self.billing_reader { + Some(repo) => repo.void_provider_expense(id, operator).await, + None => Ok(AdminBillingMutationOutcome::Unavailable), + } + } + pub(crate) async fn list_billing_plans( &self, include_disabled: bool, @@ -2819,6 +2937,21 @@ impl GatewayDataState { } } + pub(crate) async fn list_user_plan_entitlements_with_history( + &self, + user_id: &str, + include_inactive: bool, + ) -> Result>, DataLayerError> { + match &self.billing_reader { + Some(repository) => { + repository + .list_user_plan_entitlements_with_history(user_id, include_inactive) + .await + } + None => Ok(None), + } + } + pub(crate) async fn revoke_user_plan_entitlement( &self, user_id: &str, diff --git a/apps/aether-gateway/src/execution_activity.rs b/apps/aether-gateway/src/execution_activity.rs new file mode 100644 index 000000000..a796bea9b --- /dev/null +++ b/apps/aether-gateway/src/execution_activity.rs @@ -0,0 +1,538 @@ +//! Node-local, request-deduplicated activity for provider and requested-model analysis. +//! +//! RPM counts distinct requests entering upstream execution in the last 60 seconds; +//! it is never extrapolated from a shorter observation window. Concurrency follows +//! guard lifetimes, including streams, independently of that window. Expiration is +//! ordered rather than scanning request history on each lifecycle event. +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; + +const WINDOW_US: u64 = 60_000_000; +const MAX_REQUESTS: usize = 100_000; +const MAX_REQUEST_DIMENSIONS: usize = 200_000; +const MAX_LABEL_BYTES: usize = 512; + +#[derive(Debug, Default)] +struct Counts { + recent: u64, + active: u64, + provider_name: Option>, +} + +impl Counts { + fn empty(&self) -> bool { + self.recent == 0 && self.active == 0 + } +} + +#[derive(Debug)] +struct ProviderRequest { + active: u64, +} + +#[derive(Debug)] +struct Request { + model: Option>, + active: u64, + providers: HashMap, ProviderRequest>, + idle_since: Option, + cleanup_scheduled: bool, +} + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +enum Expiration { + Model(Arc), + Provider(Arc, Arc), + Request(Arc), +} + +#[derive(Debug, Default)] +struct History { + through_us: u64, + requests: HashMap, Request>, + providers: HashMap, Counts>, + models: HashMap>, Counts>, + expirations: BinaryHeap>, + request_dimensions: usize, + untracked_active: u64, + incomplete_until_us: u64, +} + +impl History { + fn advance(&mut self, now_us: u64) { + self.through_us = self.through_us.max(now_us); + while self + .expirations + .peek() + .is_some_and(|Reverse((expires_at, _))| *expires_at <= self.through_us) + { + let Reverse((_, expiration)) = self.expirations.pop().expect("expiration exists"); + match expiration { + Expiration::Model(request_id) => { + let Some(request) = self.requests.get_mut(&request_id) else { + continue; + }; + if let Some(counts) = self.models.get_mut(&request.model) { + counts.recent = counts.recent.saturating_sub(1); + if counts.empty() { + self.models.remove(&request.model); + } + } + } + Expiration::Provider(request_id, provider_id) => { + let Some(request) = self.requests.get(&request_id) else { + continue; + }; + if !request.providers.contains_key(&provider_id) { + continue; + } + if let Some(counts) = self.providers.get_mut(&provider_id) { + counts.recent = counts.recent.saturating_sub(1); + if counts.empty() { + self.providers.remove(&provider_id); + } + } + } + Expiration::Request(request_id) => { + let Some(request) = self.requests.get_mut(&request_id) else { + continue; + }; + request.cleanup_scheduled = false; + if let Some(idle_since) = request.idle_since { + let expires_at = idle_since.saturating_add(WINDOW_US); + if expires_at <= self.through_us { + self.request_dimensions -= request.providers.len() + 1; + self.requests.remove(&request_id); + } else { + // A retry reused the record while its first cleanup was + // pending. Keep at most one cleanup entry per request. + request.cleanup_scheduled = true; + self.expirations + .push(Reverse((expires_at, Expiration::Request(request_id)))); + } + } + } + } + } + } + + fn begin( + &mut self, + now_us: u64, + request_id: &str, + provider_id: &str, + provider_name: Option<&str>, + requested_model: Option<&str>, + ) -> GuardIdentity { + self.advance(now_us); + let existing = self.requests.get(request_id); + let new_request = existing.is_none(); + let new_provider = existing.is_none_or(|r| !r.providers.contains_key(provider_id)); + let new_dimensions = usize::from(new_request) + usize::from(new_provider); + let valid_labels = !request_id.is_empty() + && !provider_id.is_empty() + && [ + Some(request_id), + Some(provider_id), + provider_name, + requested_model, + ] + .into_iter() + .flatten() + .all(|label| label.len() <= MAX_LABEL_BYTES); + if !valid_labels + || (new_request && self.requests.len() >= MAX_REQUESTS) + || self.request_dimensions.saturating_add(new_dimensions) > MAX_REQUEST_DIMENSIONS + { + // Telemetry must not affect admission. Explicitly mark incomplete + // coverage instead of silently returning plausible but partial counts. + self.untracked_active += 1; + self.incomplete_until_us = self.through_us.saturating_add(WINDOW_US); + return GuardIdentity::Untracked; + } + + let request_id: Arc = self + .requests + .get_key_value(request_id) + .map(|(key, _)| Arc::clone(key)) + .unwrap_or_else(|| Arc::from(request_id)); + let request = self + .requests + .entry(Arc::clone(&request_id)) + .or_insert_with(|| Request { + model: requested_model + .filter(|model| !model.is_empty()) + .map(Arc::from), + active: 0, + providers: HashMap::new(), + idle_since: None, + cleanup_scheduled: false, + }); + let model_counts = self.models.entry(request.model.clone()).or_default(); + if new_request { + model_counts.recent += 1; + self.expirations.push(Reverse(( + self.through_us.saturating_add(WINDOW_US), + Expiration::Model(Arc::clone(&request_id)), + ))); + } + if request.active == 0 { + model_counts.active += 1; + } + request.active += 1; + request.idle_since = None; + + let provider_id: Arc = request + .providers + .get_key_value(provider_id) + .map(|(key, _)| Arc::clone(key)) + .unwrap_or_else(|| Arc::from(provider_id)); + let provider = request + .providers + .entry(Arc::clone(&provider_id)) + .or_insert(ProviderRequest { active: 0 }); + let provider_counts = self.providers.entry(Arc::clone(&provider_id)).or_default(); + if let Some(name) = provider_name.filter(|name| !name.is_empty()) { + provider_counts.provider_name = Some(Arc::from(name)); + } + if new_provider { + provider_counts.recent += 1; + self.expirations.push(Reverse(( + self.through_us.saturating_add(WINDOW_US), + Expiration::Provider(Arc::clone(&request_id), Arc::clone(&provider_id)), + ))); + } + if provider.active == 0 { + provider_counts.active += 1; + } + provider.active += 1; + self.request_dimensions += new_dimensions; + GuardIdentity::Tracked { + request_id, + provider_id, + } + } + + fn release(&mut self, now_us: u64, identity: GuardIdentity) { + self.advance(now_us); + let GuardIdentity::Tracked { + request_id, + provider_id, + } = identity + else { + self.untracked_active = self.untracked_active.saturating_sub(1); + return; + }; + let Some(request) = self.requests.get_mut(&request_id) else { + return; + }; + let Some(provider) = request.providers.get_mut(&provider_id) else { + return; + }; + provider.active = provider.active.saturating_sub(1); + if provider.active == 0 { + if let Some(counts) = self.providers.get_mut(&provider_id) { + counts.active = counts.active.saturating_sub(1); + if counts.empty() { + self.providers.remove(&provider_id); + } + } + } + request.active = request.active.saturating_sub(1); + if request.active == 0 { + if let Some(counts) = self.models.get_mut(&request.model) { + counts.active = counts.active.saturating_sub(1); + if counts.empty() { + self.models.remove(&request.model); + } + } + // Retain deduplication briefly after completion as failover may begin + // after the old guard drops, including after a >60-second attempt. + request.idle_since = Some(self.through_us); + if !request.cleanup_scheduled { + request.cleanup_scheduled = true; + self.expirations.push(Reverse(( + self.through_us.saturating_add(WINDOW_US), + Expiration::Request(request_id), + ))); + } + } + } + + fn snapshot(&mut self, now_us: u64, started_at_us: i64) -> Value { + self.advance(now_us); + let mut providers: Vec<_> = self.providers.iter().collect(); + providers.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + let mut models: Vec<_> = self.models.iter().collect(); + models.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + json!({ + "observed_at": DateTime::from_timestamp_micros(started_at_us.saturating_add(self.through_us.min(i64::MAX as u64) as i64)), + "observed_from": DateTime::from_timestamp_micros(started_at_us), + "window_seconds": 60, + "observed_window_seconds": (self.through_us as f64 / 1_000_000.0).min(60.0), + "scope": {"kind": "node"}, + "measurement": "http_and_responses_websocket_requests", + "coverage": if self.untracked_active > 0 || self.through_us < self.incomplete_until_us { "partial" } else { "complete" }, + "providers": providers.into_iter().map(|(id, counts)| json!({ + "provider_id": id.as_ref(), + "provider": counts.provider_name.as_deref().unwrap_or(id.as_ref()), + "requests_per_minute": counts.recent, + "current_concurrency": counts.active, + })).collect::>(), + "models": models.into_iter().map(|(model, counts)| json!({ + "model": model.as_deref(), + "requests_per_minute": counts.recent, + "current_concurrency": counts.active, + })).collect::>(), + }) + } +} + +#[derive(Debug)] +pub(crate) struct ExecutionActivity { + started_at: Instant, + started_at_us: i64, + history: Mutex, +} + +impl Default for ExecutionActivity { + fn default() -> Self { + Self { + started_at: Instant::now(), + started_at_us: Utc::now().timestamp_micros(), + history: Mutex::new(History::default()), + } + } +} + +impl ExecutionActivity { + fn elapsed_us(&self) -> u64 { + self.started_at.elapsed().as_micros().min(u64::MAX as u128) as u64 + } + + pub(crate) fn begin( + self: &Arc, + request_id: &str, + provider_id: &str, + provider_name: Option<&str>, + requested_model: Option<&str>, + ) -> ExecutionActivityGuard { + let identity = self + .history + .lock() + .unwrap_or_else(|e| e.into_inner()) + .begin( + self.elapsed_us(), + request_id, + provider_id, + provider_name, + requested_model, + ); + ExecutionActivityGuard { + activity: Arc::clone(self), + identity: Some(identity), + } + } + + pub(crate) fn snapshot(&self) -> Value { + self.history + .lock() + .unwrap_or_else(|e| e.into_inner()) + .snapshot(self.elapsed_us(), self.started_at_us) + } +} + +#[derive(Debug)] +enum GuardIdentity { + Tracked { + request_id: Arc, + provider_id: Arc, + }, + Untracked, +} + +#[derive(Debug)] +pub(crate) struct ExecutionActivityGuard { + activity: Arc, + identity: Option, +} + +impl Drop for ExecutionActivityGuard { + fn drop(&mut self) { + if let Some(identity) = self.identity.take() { + self.activity + .history + .lock() + .unwrap_or_else(|e| e.into_inner()) + .release(self.activity.elapsed_us(), identity); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn begin(history: &mut History, at_us: u64, id: &str, provider: &str) -> GuardIdentity { + history.begin(at_us, id, provider, Some(provider), Some("requested-model")) + } + + fn value(history: &mut History, at_us: u64) -> Value { + history.snapshot(at_us, 0) + } + + #[test] + fn rpm_has_an_exact_rolling_window_and_never_extrapolates_startup() { + let mut history = History::default(); + let a = begin(&mut history, 0, "a", "provider"); + history.release(1, a); + let b = begin(&mut history, 30_000_000, "b", "provider"); + history.release(30_000_001, b); + let early = value(&mut history, 30_000_001); + assert_eq!(early["providers"][0]["requests_per_minute"], 2); + assert_eq!(early["coverage"], "complete"); + assert!(early["observed_window_seconds"].as_f64().unwrap() < 60.0); + assert_eq!( + value(&mut history, WINDOW_US - 1)["providers"][0]["requests_per_minute"], + 2 + ); + assert_eq!( + value(&mut history, WINDOW_US)["providers"][0]["requests_per_minute"], + 1 + ); + assert!(value(&mut history, 90_000_000)["providers"] + .as_array() + .unwrap() + .is_empty()); + assert_eq!( + value(&mut history, 90_000_000)["observed_window_seconds"], + 60.0 + ); + } + + #[test] + fn overlapping_guards_and_sequential_retries_count_one_request() { + let mut history = History::default(); + let a = begin(&mut history, 0, "request", "provider"); + let b = begin(&mut history, 1, "request", "provider"); + let c = begin(&mut history, 2, "other-request", "provider"); + assert_eq!( + value(&mut history, 2)["providers"][0]["current_concurrency"], + 2 + ); + assert_eq!( + value(&mut history, 2)["providers"][0]["requests_per_minute"], + 2 + ); + history.release(3, a); + assert_eq!( + value(&mut history, 3)["models"][0]["current_concurrency"], + 2 + ); + history.release(4, b); + history.release(5, c); + let retry = begin(&mut history, 6, "request", "provider"); + let result = value(&mut history, 6); + assert_eq!(result["providers"][0]["requests_per_minute"], 2); + assert_eq!(result["models"][0]["current_concurrency"], 1); + history.release(7, retry); + } + + #[test] + fn failover_counts_each_provider_but_deduplicates_the_requested_model() { + let mut history = History::default(); + let first = begin(&mut history, 0, "request", "first"); + let second = begin(&mut history, 1, "request", "second"); + let result = value(&mut history, 2); + assert_eq!(result["providers"].as_array().unwrap().len(), 2); + assert_eq!(result["providers"][0]["requests_per_minute"], 1); + assert_eq!(result["providers"][1]["current_concurrency"], 1); + assert_eq!(result["models"][0]["requests_per_minute"], 1); + assert_eq!(result["models"][0]["current_concurrency"], 1); + history.release(3, first); + history.release(4, second); + } + + #[test] + fn long_stream_retains_concurrency_and_retry_does_not_restart_model_rpm() { + let mut history = History::default(); + let stream = begin(&mut history, 0, "request", "provider"); + let result = value(&mut history, 2 * WINDOW_US); + assert_eq!(result["providers"][0]["requests_per_minute"], 0); + assert_eq!(result["models"][0]["current_concurrency"], 1); + history.release(2 * WINDOW_US + 1, stream); + let retry = begin(&mut history, 2 * WINDOW_US + 2, "request", "provider"); + let result = value(&mut history, 2 * WINDOW_US + 2); + assert_eq!(result["providers"][0]["requests_per_minute"], 0); + assert_eq!(result["models"][0]["requests_per_minute"], 0); + assert_eq!(result["models"][0]["current_concurrency"], 1); + history.release(2 * WINDOW_US + 3, retry); + assert!(value(&mut history, 3 * WINDOW_US + 3)["models"] + .as_array() + .unwrap() + .is_empty()); + assert!(history.requests.is_empty()); + assert!(history.expirations.is_empty()); + assert_eq!(history.request_dimensions, 0); + } + + #[test] + fn cancellation_drop_releases_concurrency_but_keeps_rpm() { + let activity = Arc::new(ExecutionActivity::default()); + let guard = activity.begin("request", "provider", Some("Provider name"), None); + assert_eq!( + activity.snapshot()["providers"][0]["current_concurrency"], + 1 + ); + drop(guard); + let result = activity.snapshot(); + assert_eq!(result["providers"][0]["current_concurrency"], 0); + assert_eq!(result["providers"][0]["requests_per_minute"], 1); + assert_eq!(result["providers"][0]["provider"], "Provider name"); + assert!(result["models"][0]["model"].is_null()); + } + + #[test] + fn retry_cleanup_entries_stay_bounded_and_idle_memory_is_released() { + let mut history = History::default(); + for n in 0..1_000 { + let guard = begin(&mut history, n, "request", "provider"); + history.release(n, guard); + } + assert_eq!(history.expirations.len(), 3); + value(&mut history, WINDOW_US); + assert_eq!(history.expirations.len(), 1); + assert_eq!(history.requests.len(), 1); + value(&mut history, WINDOW_US + 1_000); + assert!(history.requests.is_empty()); + assert!(history.providers.is_empty()); + assert!(history.models.is_empty()); + assert!(history.expirations.is_empty()); + assert_eq!(history.request_dimensions, 0); + } + + #[test] + fn sampling_limits_report_incomplete_coverage_until_unobserved_work_expires() { + let mut history = History::default(); + history.request_dimensions = MAX_REQUEST_DIMENSIONS; + let untracked = begin(&mut history, 0, "request", "provider"); + assert_eq!(value(&mut history, 1)["coverage"], "partial"); + assert_eq!(value(&mut history, 2 * WINDOW_US)["coverage"], "partial"); + history.release(2 * WINDOW_US, untracked); + assert_eq!(value(&mut history, 2 * WINDOW_US)["coverage"], "complete"); + history.request_dimensions = 0; + let long_id = "x".repeat(MAX_LABEL_BYTES + 1); + let untracked = begin(&mut history, 3 * WINDOW_US, &long_id, "provider"); + history.release(3 * WINDOW_US, untracked); + assert_eq!( + value(&mut history, 4 * WINDOW_US - 1)["coverage"], + "partial" + ); + assert_eq!(value(&mut history, 4 * WINDOW_US)["coverage"], "complete"); + } +} diff --git a/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs b/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs index 0a1d388af..b483cbd8f 100644 --- a/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs +++ b/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs @@ -181,6 +181,12 @@ async fn settle_cancelled_attempt( usage_data.request_metadata.take(), request_diagnostics.as_ref(), ); + usage_data.request_metadata = crate::usage::reporting::failure::with_analytics_failure( + usage_data.request_metadata.as_ref(), + "unknown", + "finalize", + "request_task_cancelled", + ); usage_data.status_code = Some(CLIENT_CANCELLED_STATUS_CODE); usage_data.error_message = Some(error_message.to_string()); usage_data.error_category = Some("cancelled".to_string()); diff --git a/apps/aether-gateway/src/execution_runtime/attempt_lifecycle.rs b/apps/aether-gateway/src/execution_runtime/attempt_lifecycle.rs index 6c9d80623..8d67eefa1 100644 --- a/apps/aether-gateway/src/execution_runtime/attempt_lifecycle.rs +++ b/apps/aether-gateway/src/execution_runtime/attempt_lifecycle.rs @@ -668,8 +668,22 @@ impl ExecutionAttemptLifecycle { }); // 1. usage terminal + let analytics_context = if facts.provider.cancelled_by_provider() { + crate::usage::reporting::failure::with_analytics_failure( + payload.report_context.as_ref(), + "upstream", + "stream_read", + "provider_cancelled", + ) + } else { + crate::usage::reporting::failure::stream_analytics_context( + payload.report_context.as_ref(), + &payload, + facts.delivery.is_aborted() && !facts.provider.is_terminal(), + ) + }; let context_seed = - build_terminal_usage_context_seed(&self.plan, payload.report_context.as_ref()); + build_terminal_usage_context_seed(&self.plan, analytics_context.as_ref()); let payload_seed = build_stream_terminal_usage_payload_seed(&payload); let billing_void = settlement.billing.is_void(); let usage_runtime = Arc::clone(&state.usage_runtime); diff --git a/apps/aether-gateway/src/execution_runtime/grok.rs b/apps/aether-gateway/src/execution_runtime/grok.rs index a44e2d792..ab95832f8 100644 --- a/apps/aether-gateway/src/execution_runtime/grok.rs +++ b/apps/aether-gateway/src/execution_runtime/grok.rs @@ -1114,6 +1114,7 @@ fn grok_canonical_usage(usage: GrokUsageEstimate) -> StreamingCanonicalUsage { fn grok_standardized_usage(usage: GrokUsageEstimate) -> StandardizedUsage { let mut standardized = StandardizedUsage::new(); + standardized.token_source = Some(aether_contracts::UsageTokenSource::Estimated); standardized.input_tokens = i64::try_from(usage.input_tokens).unwrap_or(i64::MAX); standardized.output_tokens = i64::try_from(usage.output_tokens).unwrap_or(i64::MAX); standardized.reasoning_tokens = i64::try_from(usage.reasoning_tokens).unwrap_or(i64::MAX); @@ -4574,6 +4575,101 @@ mod tests { assert!(adapter.text.contains("[[1]](https://example.com/source")); } + #[test] + fn grok_usage_reports_preserve_estimated_provenance_after_wire_roundtrip() { + use aether_usage_runtime::{ + build_stream_terminal_usage_event, build_sync_terminal_usage_event, + GatewayStreamReportRequest, GatewaySyncReportRequest, UsageEventType, + }; + + for (format, report_prefix) in [ + ("openai:chat", "openai_chat"), + ("openai:responses", "openai_responses"), + ] { + let mut plan = sample_plan( + serde_json::json!({ + "messages": [{"role": "user", "content": "hello"}] + }), + format, + ); + plan.stream = false; + plan.provider_api_format = format.to_string(); + // The trusted planner binds this hint to the Grok runtime adapter. + // Exercise its transport through the same serialized report as usage. + let context = serde_json::json!({ + "provider_type": "grok", + "provider_api_format": format, + "client_api_format": format, + "usage_token_source": "estimated" + }); + let collected = GrokCollected { + status_code: 200, + text: "hello back".to_string(), + thinking: "short reasoning".to_string(), + ..GrokCollected::default() + }; + let expected = grok_usage_estimate(&plan, &collected); + let result = grok_execution_result(&plan, collected, Some(&context)); + let sync_report = GatewaySyncReportRequest { + trace_id: plan.request_id.clone(), + report_kind: format!("{report_prefix}_sync_success"), + report_context: Some(context.clone()), + status_code: result.status_code, + headers: result.headers, + body_json: result.body.and_then(|body| body.json_body), + client_body_json: None, + body_base64: None, + telemetry: result.telemetry, + }; + let sync_report: GatewaySyncReportRequest = + serde_json::from_slice(&serde_json::to_vec(&sync_report).unwrap()).unwrap(); + let sync_event = build_sync_terminal_usage_event( + &plan, + sync_report.report_context.as_ref(), + &sync_report, + ) + .unwrap(); + + plan.stream = true; + let stream_report = GatewayStreamReportRequest { + trace_id: plan.request_id.clone(), + report_kind: format!("{report_prefix}_stream_success"), + report_context: Some(context), + status_code: 200, + headers: BTreeMap::new(), + provider_body_base64: None, + provider_body_state: None, + client_body_base64: None, + client_body_state: None, + terminal_summary: Some(super::grok_stream_terminal_summary(&plan, expected)), + telemetry: None, + }; + let stream_report: GatewayStreamReportRequest = + serde_json::from_slice(&serde_json::to_vec(&stream_report).unwrap()).unwrap(); + let stream_event = build_stream_terminal_usage_event( + &plan, + stream_report.report_context.as_ref(), + &stream_report, + ) + .unwrap(); + + // Sync honors the response's explicit total. The existing stream + // summary has no explicit total, so its fallback also adds reasoning. + let sync_total = expected.input_tokens + expected.output_tokens; + let stream_total = sync_total + expected.reasoning_tokens; + for (event, expected_total) in [(sync_event, sync_total), (stream_event, stream_total)] + { + assert_eq!(event.event_type, UsageEventType::Completed, "{format}"); + assert_eq!(event.data.input_tokens, Some(expected.input_tokens)); + assert_eq!(event.data.output_tokens, Some(expected.output_tokens)); + assert_eq!(event.data.total_tokens, Some(expected_total)); + let metadata = event.data.request_metadata.unwrap(); + assert_eq!(metadata["analytics_measurement"]["source"], "estimated"); + assert!(metadata.get("usage_token_source").is_none()); + } + } + } + #[test] fn openai_chat_body_includes_estimated_usage() { let plan = sample_plan( diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs index d9d30a4c6..2ae4e283e 100644 --- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; use aether_ai_serving::{AiAttemptExecutionOutcome, AiAttemptRetryScope}; use aether_contracts::{ ExecutionPlan, ExecutionResponseObservation, ExecutionStreamTerminalSummary, - ExecutionTelemetry, StandardizedUsage, StreamFrame, StreamFramePayload, + ExecutionTelemetry, StandardizedUsage, StreamFrame, StreamFramePayload, UsageTokenSource, }; use aether_data_contracts::repository::candidates::{ RequestCandidateStatus, UpsertRequestCandidateRecord, @@ -445,11 +445,15 @@ fn build_sync_terminal_usage_seeds( report_context: Option<&serde_json::Value>, payload: &GatewaySyncReportRequest, ) -> (TerminalUsageContextSeed, SyncTerminalUsagePayloadSeed) { + let analytics_context = + crate::usage::reporting::failure::sync_analytics_context(report_context, payload); let report_context_with_diagnostics = - attach_current_request_diagnostics_to_report_context(report_context); + attach_current_request_diagnostics_to_report_context(analytics_context.as_ref()); let context_seed = build_terminal_usage_context_seed( plan, - report_context_with_diagnostics.as_ref().or(report_context), + report_context_with_diagnostics + .as_ref() + .or(analytics_context.as_ref()), ); let payload_seed = build_sync_terminal_usage_payload_seed(payload); (context_seed, payload_seed) @@ -586,7 +590,12 @@ async fn record_stream_terminal_usage( cancelled: bool, ) { crate::execution_runtime::mark_stream_candidate_watchdog_terminal_started(); - let context_seed = build_terminal_usage_context_seed(plan, report_context); + let analytics_context = crate::usage::reporting::failure::stream_analytics_context( + report_context, + payload, + cancelled, + ); + let context_seed = build_terminal_usage_context_seed(plan, analytics_context.as_ref()); let payload_seed = build_stream_terminal_usage_payload_seed(payload); state .usage_runtime @@ -976,6 +985,9 @@ async fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary( usage.cache_read_tokens = 0; if usage.input_tokens <= 0 { usage.input_tokens = estimated_input_tokens as i64; + if usage.input_tokens > 0 { + mark_kiro_stream_estimated_usage(usage, report_context, false); + } } return; } @@ -984,6 +996,10 @@ async fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary( usage.input_tokens = kiro_billed_input_tokens(estimated_input_tokens, cache_usage) as i64; usage.cache_creation_tokens = cache_usage.cache_creation_input_tokens as i64; usage.cache_read_tokens = cache_usage.cache_read_input_tokens as i64; + if usage.input_tokens > 0 || usage.cache_creation_tokens > 0 || usage.cache_read_tokens > 0 + { + mark_kiro_stream_estimated_usage(usage, report_context, false); + } return; } @@ -996,12 +1012,18 @@ async fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary( cache_read_input_tokens: usage.cache_read_tokens.max(0) as u64, }, ) as i64; + if usage.input_tokens > 0 { + mark_kiro_stream_estimated_usage(usage, report_context, true); + } } return; } if usage.input_tokens <= 0 { usage.input_tokens = estimated_input_tokens as i64; + if usage.input_tokens > 0 { + mark_kiro_stream_estimated_usage(usage, report_context, true); + } } let Some(profile) = @@ -1024,6 +1046,35 @@ async fn maybe_apply_kiro_prompt_cache_usage_to_stream_summary( usage.input_tokens = billed_input_tokens as i64; usage.cache_creation_tokens = cache_usage.cache_creation_input_tokens as i64; usage.cache_read_tokens = cache_usage.cache_read_input_tokens as i64; + mark_kiro_stream_estimated_usage(usage, report_context, false); +} + +fn mark_kiro_stream_estimated_usage( + usage: &mut StandardizedUsage, + report_context: &Value, + retains_cache: bool, +) { + let retained_source = usage.token_source.unwrap_or_else(|| { + match report_context + .get("usage_token_source") + .and_then(Value::as_str) + { + Some("estimated") => UsageTokenSource::Estimated, + Some("mixed") => UsageTokenSource::Mixed, + _ => UsageTokenSource::Reported, + } + }); + let retains_reported_tokens = retained_source != UsageTokenSource::Estimated + && (usage.output_tokens > 0 + || usage.reasoning_tokens > 0 + || usage.cache_creation_ephemeral_5m_tokens > 0 + || usage.cache_creation_ephemeral_1h_tokens > 0 + || (retains_cache && (usage.cache_creation_tokens > 0 || usage.cache_read_tokens > 0))); + usage.token_source = Some(if retains_reported_tokens { + UsageTokenSource::Mixed + } else { + UsageTokenSource::Estimated + }); } fn append_stream_capture_bytes( @@ -3963,7 +4014,7 @@ async fn execute_execution_runtime_stream_inner( let candidate_started_unix_secs = current_request_candidate_unix_ms(); let provider_in_flight_started_at = Instant::now(); let mut provider_pool_in_flight_guard = - match acquire_provider_pool_execution_guard(state, &plan).await? { + match acquire_provider_pool_execution_guard(state, &plan, report_context.as_ref()).await? { ProviderPoolInFlightAdmission::Acquired(guard) => guard, ProviderPoolInFlightAdmission::Saturated { limit } => { record_local_runtime_candidate_skip_reason( @@ -12293,6 +12344,10 @@ mod tests { .expect("first usage should exist"); assert!(first_usage.cache_creation_tokens > 0); assert_eq!(first_usage.cache_read_tokens, 0); + assert_eq!( + first_usage.token_source, + Some(aether_contracts::UsageTokenSource::Mixed) + ); let mut second_summary = Some(ExecutionStreamTerminalSummary { standardized_usage: Some(StandardizedUsage { @@ -12317,6 +12372,10 @@ mod tests { assert_eq!(second_usage.cache_creation_tokens, 0); assert!(second_usage.input_tokens < 6_000); assert_eq!(second_usage.output_tokens, 19); + assert_eq!( + second_usage.token_source, + Some(aether_contracts::UsageTokenSource::Mixed) + ); } #[tokio::test] @@ -12512,6 +12571,49 @@ mod tests { assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.cache_read_tokens, 0); assert_eq!(usage.output_tokens, 13); + assert_eq!( + usage.token_source, + Some(aether_contracts::UsageTokenSource::Mixed) + ); + + use aether_contracts::UsageTokenSource::{Estimated, Mixed}; + for (hint, source, input, output, cache, expected) in [ + (Some("estimated"), None, 0, 13, 0, Some(Estimated)), + (None, Some(Estimated), 0, 13, 0, Some(Estimated)), + (None, None, 0, 0, 200, Some(Mixed)), + (None, None, 0, 0, 0, Some(Estimated)), + (None, None, 50, 13, 0, None), + ] { + let mut context = report_context.clone(); + if let Some(hint) = hint { + context["usage_token_source"] = json!(hint); + } + let mut summary = Some(ExecutionStreamTerminalSummary { + standardized_usage: Some(StandardizedUsage { + token_source: source, + input_tokens: input, + output_tokens: output, + cache_read_tokens: cache, + ..StandardizedUsage::new() + }), + ..Default::default() + }); + maybe_apply_kiro_prompt_cache_usage_to_stream_summary( + &state, + &plan, + Some(&context), + &mut summary, + ) + .await; + let usage = summary.unwrap().standardized_usage.unwrap(); + assert!(usage.input_tokens > 0); + assert_eq!(usage.output_tokens, output); + assert_eq!(usage.cache_read_tokens, cache); + assert_eq!( + usage.token_source, expected, + "hint={hint:?}, source={source:?}" + ); + } } #[tokio::test] @@ -12751,6 +12853,10 @@ mod tests { assert_eq!(usage.cache_creation_tokens, 175); assert_eq!(usage.cache_read_tokens, 24_463); assert_eq!(usage.output_tokens, 167); + assert_eq!( + usage.token_source, + Some(aether_contracts::UsageTokenSource::Mixed) + ); } #[tokio::test] diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs b/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs index 2a7224a93..b8fe050d2 100644 --- a/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs +++ b/apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs @@ -45,6 +45,7 @@ pub(super) struct StreamFailureReport { honor_http_failover: bool, extra_error_fields: Map, provider_body_json: Option, + analytics_failure: Option, } #[derive(Serialize)] @@ -133,6 +134,7 @@ impl StreamFailureReport { honor_http_failover: _, mut extra_error_fields, provider_body_json, + analytics_failure: _, } = self; extra_error_fields.insert("type".to_string(), Value::String(error_type)); extra_error_fields.insert("message".to_string(), Value::String(error_message)); @@ -178,6 +180,7 @@ pub(super) fn build_stream_failure_report( honor_http_failover: false, extra_error_fields: Map::new(), provider_body_json: None, + analytics_failure: None, } } @@ -196,6 +199,7 @@ pub(super) fn build_stream_transport_failure_report( honor_http_failover: false, extra_error_fields: Map::new(), provider_body_json: None, + analytics_failure: None, } } @@ -241,6 +245,10 @@ pub(super) fn build_stream_failure_from_execution_error( honor_http_failover: error.upstream_status.is_some(), extra_error_fields: error_object, provider_body_json: None, + analytics_failure: crate::usage::reporting::failure::execution_error_analytics_context( + None, error, + ) + .and_then(|context| context.get("analytics_failure").cloned()), } } @@ -271,6 +279,7 @@ pub(super) fn build_stream_failure_from_provider_error_body( honor_http_failover: true, extra_error_fields: Map::new(), provider_body_json: Some(body_json.clone()), + analytics_failure: None, } } @@ -334,6 +343,7 @@ fn build_stream_failure_sync_payload( let status_code = failure.status_code; let upstream_status_code = failure.upstream_status_code; let transport_error = failure.transport_error; + let analytics_failure = failure.analytics_failure.clone(); let (body, client_body) = failure.into_body_jsons(); headers.retain(|name, _| { !name.eq_ignore_ascii_case("content-encoding") @@ -355,6 +365,9 @@ fn build_stream_failure_sync_payload( .or(report_context); let report_context = report_context.map(|mut context| { if let Some(object) = context.as_object_mut() { + if let Some(failure) = analytics_failure { + object.insert("analytics_failure".into(), failure); + } let response_headers = serde_json::to_value(&headers).unwrap_or(Value::Null); if upstream_status_code.is_some() { object.insert( @@ -499,9 +512,11 @@ async fn record_stream_sync_failure( ); if !matches!(handling, StreamFailureHandling::HonorLocalFailover) || !retrying_next_candidate { crate::execution_runtime::mark_stream_candidate_watchdog_terminal_started(); + let analytics_context = + crate::usage::reporting::failure::sync_analytics_context(report_context, payload); let report_context_with_diagnostics = attach_current_request_diagnostics_and_candidate_timing_to_report_context( - report_context, + analytics_context.as_ref(), payload .telemetry .as_ref() @@ -513,7 +528,9 @@ async fn record_stream_sync_failure( ); let context_seed = build_terminal_usage_context_seed( plan, - report_context_with_diagnostics.as_ref().or(report_context), + report_context_with_diagnostics + .as_ref() + .or(analytics_context.as_ref()), ); let payload_seed = build_sync_terminal_usage_payload_seed(payload); state @@ -779,9 +796,13 @@ async fn handle_prefetch_transport_stream_failure( && matches!(analysis.decision, LocalFailoverDecision::RetryNextCandidate); if !retrying_next_candidate { crate::execution_runtime::mark_stream_candidate_watchdog_terminal_started(); + let analytics_context = crate::usage::reporting::failure::sync_analytics_context( + payload.report_context.as_ref(), + &payload, + ); let report_context_with_diagnostics = attach_current_request_diagnostics_and_candidate_timing_to_report_context( - payload.report_context.as_ref(), + analytics_context.as_ref(), payload .telemetry .as_ref() @@ -796,7 +817,7 @@ async fn handle_prefetch_transport_stream_failure( plan, report_context_with_diagnostics .as_ref() - .or(payload.report_context.as_ref()), + .or(analytics_context.as_ref()), ); let payload_seed = build_sync_terminal_usage_payload_seed(&payload); state diff --git a/apps/aether-gateway/src/execution_runtime/sync/execution.rs b/apps/aether-gateway/src/execution_runtime/sync/execution.rs index 37abb8c18..7a97bed07 100644 --- a/apps/aether-gateway/src/execution_runtime/sync/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/sync/execution.rs @@ -243,7 +243,10 @@ impl SyncAttemptTerminalGuard { record_sync_attempt_forced_terminal_state( self.state.clone(), self.plan.clone(), - self.report_context.clone(), + crate::usage::reporting::failure::gateway_error_analytics_context( + self.report_context.as_ref(), + error, + ), self.request_diagnostics.clone(), self.candidate_started_unix_ms, self.candidate_started_at, @@ -317,6 +320,16 @@ async fn record_sync_attempt_forced_terminal_state( let error_message = error_message.into(); let report_context = attach_request_diagnostics_to_report_context(report_context, request_diagnostics.as_ref()); + let report_context = if matches!(usage_event_type, UsageEventType::Cancelled) { + crate::usage::reporting::failure::with_analytics_failure( + report_context.as_ref(), + "unknown", + "finalize", + "request_task_cancelled", + ) + } else { + report_context + }; let terminal_unix_ms = current_request_candidate_unix_ms(); let latency_ms = elapsed_ms_since(candidate_started_at); record_local_request_candidate_status( @@ -614,15 +627,19 @@ async fn record_sync_terminal_usage( candidate_started_at: Instant, candidate_first_byte_elapsed_ms: Option, ) { + let analytics_context = + crate::usage::reporting::failure::sync_analytics_context(report_context, payload); let report_context_with_diagnostics = attach_current_request_diagnostics_and_candidate_start_timing_to_report_context( - report_context, + analytics_context.as_ref(), candidate_started_at, candidate_first_byte_elapsed_ms, ); let context_seed = build_terminal_usage_context_seed( plan, - report_context_with_diagnostics.as_ref().or(report_context), + report_context_with_diagnostics + .as_ref() + .or(analytics_context.as_ref()), ); let payload_seed = build_sync_terminal_usage_payload_seed(payload); state @@ -2074,37 +2091,38 @@ async fn execute_execution_runtime_sync_impl( .unwrap_or_else(|| "-".to_string()); let candidate_started_at = Instant::now(); let candidate_started_unix_secs = current_request_candidate_unix_ms(); - let _provider_pool_in_flight_guard = match acquire_provider_pool_execution_guard(state, &plan) - .await? - { - ProviderPoolInFlightAdmission::Acquired(guard) => guard, - ProviderPoolInFlightAdmission::Saturated { limit } => { - record_local_runtime_candidate_skip_reason( - state, - trace_id, - "provider_key_concurrency_limit_reached", - ); - if let Some(retry_scope) = retry_scope_out.as_deref_mut() { - *retry_scope = AiAttemptRetryScope::Candidate; + let _provider_pool_in_flight_guard = + match acquire_provider_pool_execution_guard(state, &plan, report_context.as_ref()).await? { + ProviderPoolInFlightAdmission::Acquired(guard) => guard, + ProviderPoolInFlightAdmission::Saturated { limit } => { + record_local_runtime_candidate_skip_reason( + state, + trace_id, + "provider_key_concurrency_limit_reached", + ); + if let Some(retry_scope) = retry_scope_out.as_deref_mut() { + *retry_scope = AiAttemptRetryScope::Candidate; + } + record_local_request_candidate_status( + state, + &plan, + report_context.as_ref(), + SchedulerRequestCandidateStatusUpdate { + status: RequestCandidateStatus::Skipped, + status_code: Some(StatusCode::TOO_MANY_REQUESTS.as_u16()), + error_type: Some("provider_key_concurrency_limit_reached".to_string()), + error_message: Some(format!( + "provider key concurrency limit reached: {limit}" + )), + latency_ms: Some(0), + started_at_unix_ms: Some(candidate_started_unix_secs), + finished_at_unix_ms: Some(candidate_started_unix_secs), + }, + ) + .await; + return Ok(None); } - record_local_request_candidate_status( - state, - &plan, - report_context.as_ref(), - SchedulerRequestCandidateStatusUpdate { - status: RequestCandidateStatus::Skipped, - status_code: Some(StatusCode::TOO_MANY_REQUESTS.as_u16()), - error_type: Some("provider_key_concurrency_limit_reached".to_string()), - error_message: Some(format!("provider key concurrency limit reached: {limit}")), - latency_ms: Some(0), - started_at_unix_ms: Some(candidate_started_unix_secs), - finished_at_unix_ms: Some(candidate_started_unix_secs), - }, - ) - .await; - return Ok(None); - } - }; + }; let lifecycle_seed = build_lifecycle_usage_seed(&plan, report_context.as_ref()); let usage_data = state.usage_lifecycle_data_state().as_ref().clone(); state @@ -2804,6 +2822,9 @@ async fn execute_execution_runtime_sync_impl( provider_response_observation.response_headers_observed_at_unix_ms, &provider_response_observation.request_order_id, ); + if let Some(error) = result.error.as_ref() { + report_context = crate::usage::reporting::failure::execution_error_analytics_context(report_context.as_ref(), error); + } if result.status_code >= 400 { apply_local_execution_effect( state, diff --git a/apps/aether-gateway/src/execution_runtime/transport_failure.rs b/apps/aether-gateway/src/execution_runtime/transport_failure.rs index c3f1885fb..a8be5b865 100644 --- a/apps/aether-gateway/src/execution_runtime/transport_failure.rs +++ b/apps/aether-gateway/src/execution_runtime/transport_failure.rs @@ -130,6 +130,9 @@ pub(crate) async fn build_transport_error_stop_response( None => serde_json::Map::new(), }; request_metadata.insert("transport_error".to_string(), Value::Bool(true)); + request_metadata.insert("analytics_failure".into(), json!({ + "origin": "transport", "stage": "connect", "reason": "upstream_transport_error", "schema_version": 1, + })); request_metadata.insert( "transport_error_type".to_string(), Value::String(error_type.to_string()), diff --git a/apps/aether-gateway/src/executor/outcome.rs b/apps/aether-gateway/src/executor/outcome.rs index 729bf5caf..9afdd2053 100644 --- a/apps/aether-gateway/src/executor/outcome.rs +++ b/apps/aether-gateway/src/executor/outcome.rs @@ -111,6 +111,12 @@ pub(crate) fn record_failed_usage_for_deferred_response<'a>( return; }; let mut data = build_usage_event_data_seed(&context.plan, context.report_context.as_ref()); + data.request_metadata = crate::usage::reporting::failure::with_analytics_failure( + data.request_metadata.as_ref(), + "upstream", + "response", + "candidates_exhausted", + ); data.status_code = Some(status_code); data.error_message = Some("all local candidates failed; returning preserved upstream error".to_string()); @@ -390,6 +396,15 @@ pub(crate) async fn record_failed_usage_for_exhausted_request( None => Map::new(), }; request_metadata.insert("trace_id".to_string(), Value::String(request_id.clone())); + if !request_metadata.contains_key("analytics_failure") { + request_metadata.insert( + "analytics_failure".into(), + json!({ + "origin": if upstream_status_code.is_some() { "upstream" } else { "gateway" }, + "stage": "routing", "reason": "candidates_exhausted", "schema_version": 1, + }), + ); + } apply_runtime_miss_usage_routing( &mut data, &mut request_metadata, @@ -471,6 +486,9 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request( } let mut request_metadata = Map::new(); + request_metadata.insert("analytics_failure".into(), json!({ + "origin": "gateway", "stage": "routing", "reason": "execution_route_unavailable", "schema_version": 1, + })); request_metadata.insert( "trace_id".to_string(), Value::String(request_id.to_string()), diff --git a/apps/aether-gateway/src/handlers/admin/billing/mod.rs b/apps/aether-gateway/src/handlers/admin/billing/mod.rs index ad2d74c76..7cf6b630b 100644 --- a/apps/aether-gateway/src/handlers/admin/billing/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/billing/mod.rs @@ -16,6 +16,8 @@ mod collectors; mod payments; mod plans; mod presets; +mod provider_accounts; +mod provider_expenses; mod routes; mod rules; mod wallets; @@ -207,6 +209,15 @@ pub(crate) async fn maybe_build_local_admin_billing_response( return Ok(None); } + if let Some(response) = provider_accounts::response(state, request_context).await? { + return Ok(Some(response)); + } + if let Some(response) = + provider_expenses::response(state, request_context, request_body).await? + { + return Ok(Some(response)); + } + let path = request_context.path(); let is_billing_route = (request_context.method() == http::Method::GET && matches!( diff --git a/apps/aether-gateway/src/handlers/admin/billing/provider_accounts.rs b/apps/aether-gateway/src/handlers/admin/billing/provider_accounts.rs new file mode 100644 index 000000000..717f25196 --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/billing/provider_accounts.rs @@ -0,0 +1,159 @@ +//! Current provider finance snapshots. This endpoint never calls upstream services. +use super::build_admin_billing_data_unavailable_response; +use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; +use crate::GatewayError; +use axum::{ + body::Body, + http, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::{json, Value}; + +fn finite(value: Option<&Value>) -> Option { + value + .and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|v| v.parse::().ok())) + }) + .filter(|v| v.is_finite()) +} +fn text(value: Option<&Value>) -> Option<&str> { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty() && v.len() <= 256 && !v.chars().any(char::is_control)) +} +fn timestamp(value: Option<&Value>) -> Option { + let value = value?; + if let Some(raw) = value.as_str() { + if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) { + return Some(date.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); + } + } + let secs = finite(Some(value))?; + if !(0.0..=253_402_300_799.0).contains(&secs) { + return None; + } + chrono::DateTime::from_timestamp(secs as i64, 0) + .map(|v| v.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) +} +fn subscription(value: &Value) -> Value { + json!({ + "group_name": text(value.get("group_name")), + "status": text(value.get("status")), + "daily_used_usd": finite(value.get("daily_used_usd")), + "daily_limit_usd": finite(value.get("daily_limit_usd")), + "weekly_used_usd": finite(value.get("weekly_used_usd")), + "weekly_limit_usd": finite(value.get("weekly_limit_usd")), + "monthly_used_usd": finite(value.get("monthly_used_usd")), + "monthly_limit_usd": finite(value.get("monthly_limit_usd")), + "expires_at": timestamp(value.get("expires_at")), + }) +} +fn balance(value: &Value) -> Option { + if value.get("action_type").and_then(Value::as_str) != Some("query_balance") { + return None; + } + let status = text(value.get("status"))?; + if !matches!(status, "success" | "auth_expired" | "auth_failed") { + return None; + } + let data = value + .get("data") + .filter(|_| matches!(status, "success" | "auth_expired")); + let extra = data.and_then(|d| d.get("extra")); + let subscriptions = extra + .and_then(|e| e.get("subscriptions")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter(|v| v.is_object()) + .take(128) + .map(subscription) + .collect::>() + }) + .unwrap_or_default(); + Some(json!({ + "status": status, + "observed_at": timestamp(value.get("executed_at")), + "currency": data.and_then(|d| text(d.get("currency"))), + "available": data.and_then(|d| finite(d.get("total_available"))), + "used": data.and_then(|d| finite(d.get("total_used"))), + "granted": data.and_then(|d| finite(d.get("total_granted"))), + "plan_name": extra.and_then(|e| text(e.get("plan_name"))), + "subscriptions": subscriptions, + })) +} +pub(super) async fn response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, +) -> Result>, GatewayError> { + if context.method() != http::Method::GET + || context.path().trim_end_matches('/') != "/api/admin/billing/provider-accounts" + || context.route_family() != Some("billing_manage") + { + return Ok(None); + } + if !state.has_provider_catalog_data_reader() { + return Ok(Some(build_admin_billing_data_unavailable_response())); + } + let mut providers = state.list_provider_catalog_providers(false).await?; + providers.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + let keys = providers + .iter() + .map(|p| format!("provider_ops:balance:{}", p.id)) + .collect::>(); + let (cached, unavailable) = if keys.is_empty() { + (Vec::new(), false) + } else { + match state.runtime_state().kv_get_many(&keys).await { + Ok(v) => (v, false), + Err(_) => (vec![None; keys.len()], true), + } + }; + let items = providers.iter().enumerate().map(|(index, p)| { + let limit = p.monthly_quota_usd.filter(|v| v.is_finite() && *v >= 0.0); + let used = p.monthly_used_usd.filter(|v| v.is_finite() && *v >= 0.0); + let quota = if p.billing_type.as_deref() == Some("monthly_quota") || limit.is_some() { + json!({ + "limit": limit, "used": used, + "remaining": limit.zip(used).map(|(l,u)| (l-u).max(0.0)), + "currency": "USD", + "period_start": p.quota_last_reset_at_unix_secs.and_then(|v| timestamp(Some(&json!(v)))), + "expires_at": p.quota_expires_at_unix_secs.and_then(|v| timestamp(Some(&json!(v)))), + }) + } else { Value::Null }; + let balance = cached.get(index).and_then(|v| v.as_deref()) + .and_then(|v| serde_json::from_str::(v).ok()).and_then(|v| balance(&v)); + json!({ + "provider_id": p.id, "provider_name": p.name, "is_active": p.is_active, + "billing_type": p.billing_type, "quota": quota, "balance": balance, + }) + }).collect::>(); + Ok(Some(( + [(http::header::CACHE_CONTROL, "private, no-store")], + Json(json!({ + "observed_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis,true), + "items": items, "balance_snapshot_unavailable": unavailable, + })), + ).into_response())) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn provider_accounts_only_expose_finance_allowlist_and_preserve_unknown() { + let snapshot=balance(&json!({"status":"success","action_type":"query_balance","executed_at":"2026-09-20T00:00:00Z","data":{"currency":"USD","total_available":null,"extra":{"access_token":"secret","plan_name":"Pro","subscriptions":[{"group_name":"Team","monthly_used_usd":"12.25","expires_at":1800000000,"private_token":"secret"}]}}})).unwrap(); + assert!(snapshot["available"].is_null()); + assert_eq!( + snapshot["subscriptions"][0]["monthly_used_usd"], + json!(12.25) + ); + assert!(!snapshot.to_string().contains("secret")); + assert!(!snapshot.to_string().contains("access_token")); + let failed=balance(&json!({"status":"auth_failed","action_type":"query_balance","data":{"total_available":999}})).unwrap(); + assert!(failed["available"].is_null()); + } +} diff --git a/apps/aether-gateway/src/handlers/admin/billing/provider_expenses.rs b/apps/aether-gateway/src/handlers/admin/billing/provider_expenses.rs new file mode 100644 index 000000000..4cbfe2d06 --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/billing/provider_expenses.rs @@ -0,0 +1,334 @@ +use super::{ + build_admin_billing_bad_request_response as bad_request, + build_admin_billing_conflict_response as conflict, + build_admin_billing_data_unavailable_response as unavailable, + build_admin_billing_not_found_response as not_found, +}; +use crate::handlers::admin::{ + request::{AdminAppState, AdminRequestContext}, + shared::{attach_admin_audit_response, query_param_value}, +}; +use crate::handlers::shared::normalize_payment_currency; +use crate::GatewayError; +use aether_data_contracts::repository::billing::*; +use axum::{ + body::{Body, Bytes}, + http::{self, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpenseRequest { + client_request_id: String, + provider_id: String, + kind: String, + amount: String, + currency: String, + paid_at: String, + period_start: Option, + period_end: Option, + note: Option, + external_reference: Option, +} +fn datetime(value: u64) -> String { + chrono::DateTime::from_timestamp_millis(value as i64) + .expect("valid stored timestamp") + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} +fn parse_date(value: &str) -> Result { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .and_then(|v| u64::try_from(v.timestamp_millis()).ok()) + .filter(|v| *v <= 253_402_300_799_000) + .ok_or_else(|| "timestamps must be RFC3339 dates on or after 1970".into()) +} +fn optional_text(value: Option) -> Option { + value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()) +} +fn expense_json(record: &ProviderExpenseRecord) -> Value { + let e = &record.entry; + json!({ + "id": record.id, "client_request_id": e.client_request_id, + "provider_id": e.provider_id, "provider_name": e.provider_name, + "kind": e.kind, "amount": e.amount, "currency": e.currency, + "paid_at": datetime(e.paid_at_unix_ms), + "period_start": e.period_start_unix_ms.map(datetime), + "period_end": e.period_end_unix_ms.map(datetime), + "note": e.note, "external_reference": e.external_reference, + "created_by": e.created_by, "created_at": datetime(record.created_at_unix_ms), + "status": if record.voided_at_unix_ms.is_some() { "void" } else { "recorded" }, + "voided_at": record.voided_at_unix_ms.map(datetime), "voided_by": record.voided_by, + }) +} +fn csv_cell(value: &str) -> String { + let value = if value.trim_start().starts_with(['=', '+', '-', '@']) + || value.starts_with(['\t', '\r', '\n']) + { + format!("'{value}") + } else { + value.to_string() + }; + format!("\"{}\"", value.replace('"', "\"\"")) +} +fn csv_report(items: &[ProviderExpenseRecord]) -> String { + let mut result=String::from("\u{feff}id,provider_id,provider_name,kind,amount,currency,paid_at,period_start,period_end,note,external_reference,created_by,created_at\r\n"); + for r in items { + let e = &r.entry; + let fields = [ + r.id.clone(), + e.provider_id.clone(), + e.provider_name.clone(), + e.kind.clone(), + e.amount.clone(), + e.currency.clone(), + datetime(e.paid_at_unix_ms), + e.period_start_unix_ms.map(datetime).unwrap_or_default(), + e.period_end_unix_ms.map(datetime).unwrap_or_default(), + e.note.clone().unwrap_or_default(), + e.external_reference.clone().unwrap_or_default(), + e.created_by.clone().unwrap_or_default(), + datetime(r.created_at_unix_ms), + ]; + result.push_str( + &fields + .iter() + .map(|s| csv_cell(s)) + .collect::>() + .join(","), + ); + result.push_str("\r\n"); + } + result +} +fn query(context: &AdminRequestContext<'_>, csv: bool) -> Result { + let q = context.query_string(); + let now = chrono::Utc::now().timestamp_millis().max(0) as u64; + let from = query_param_value(q, "from") + .map(|v| parse_date(&v)) + .transpose()? + .unwrap_or(now.saturating_sub(30 * 86_400_000)); + let to = query_param_value(q, "to") + .map(|v| parse_date(&v)) + .transpose()? + .unwrap_or(now); + let limit = if csv { + 10_001 + } else { + query_param_value(q, "limit") + .map(|v| v.parse::().map_err(|_| "invalid limit".to_string())) + .transpose()? + .unwrap_or(25) + }; + let offset = if csv { + 0 + } else { + query_param_value(q, "offset") + .map(|v| v.parse::().map_err(|_| "invalid offset".to_string())) + .transpose()? + .unwrap_or(0) + }; + if !csv && limit > 200 { + return Err("limit must be at most 200".into()); + } + let q = ProviderExpenseQuery { + from_unix_ms: from, + to_unix_ms: to, + limit, + offset, + }; + q.validate().map_err(|e| e.to_string())?; + Ok(q) +} +pub(super) async fn response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, + body: Option<&Bytes>, +) -> Result>, GatewayError> { + let path = context.path().trim_end_matches('/'); + if context.route_family() != Some("billing_manage") + || !path.starts_with("/api/admin/billing/provider-expenses") + { + return Ok(None); + } + let operator = context + .decision() + .and_then(|d| d.admin_principal.as_ref()) + .map(|p| p.user_id.clone()); + if path == "/api/admin/billing/provider-expenses" && context.method() == http::Method::GET { + let csv = query_param_value(context.query_string(), "format").as_deref() == Some("csv"); + let q = match query(context, csv) { + Ok(v) => v, + Err(e) => return Ok(Some(bad_request(e))), + }; + let Some(page) = state + .app() + .data + .list_provider_expenses(&q) + .await + .map_err(|e| GatewayError::Internal(e.to_string()))? + else { + return Ok(Some(unavailable())); + }; + if csv { + if page.total > 10_000 { + return Ok(Some( + ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"detail":"导出超过 10000 条,请缩小时间范围"})), + ) + .into_response(), + )); + } + return Ok(Some( + ( + [ + (http::header::CONTENT_TYPE, "text/csv; charset=utf-8"), + ( + http::header::CONTENT_DISPOSITION, + "attachment; filename=provider-expenses.csv", + ), + (http::header::CACHE_CONTROL, "private, no-store"), + ], + csv_report(&page.items), + ) + .into_response(), + )); + } + return Ok(Some( + ( + [(http::header::CACHE_CONTROL, "private, no-store")], + Json(json!({ + "items": page.items.iter().map(expense_json).collect::>(), + "total": page.total, "totals": page.totals, "providers": page.providers, + "limit": q.limit, "offset": q.offset, + "from": datetime(q.from_unix_ms), "to": datetime(q.to_unix_ms), + "time_basis": "paid_at", "source": "manual_ledger", + })), + ) + .into_response(), + )); + } + if path == "/api/admin/billing/provider-expenses" && context.method() == http::Method::POST { + let Some(body) = body else { + return Ok(Some(bad_request("缺少请求体"))); + }; + let payload = match serde_json::from_slice::(body) { + Ok(v) => v, + Err(_) => return Ok(Some(bad_request("输入验证失败"))), + }; + let input = (|| -> Result { + let units = provider_expense_amount_units(&payload.amount) + .ok_or("amount must be a positive decimal string with at most 8 decimal places")?; + let input = ProviderExpenseInput { + client_request_id: uuid::Uuid::parse_str(&payload.client_request_id) + .map_err(|_| "client_request_id must be a UUID")? + .to_string(), + provider_id: payload.provider_id.trim().into(), + provider_name: "pending".into(), + kind: payload.kind, + amount: format_provider_expense_amount(units), + currency: normalize_payment_currency(&payload.currency, "currency")?, + paid_at_unix_ms: parse_date(&payload.paid_at)?, + period_start_unix_ms: payload + .period_start + .as_deref() + .map(parse_date) + .transpose()?, + period_end_unix_ms: payload.period_end.as_deref().map(parse_date).transpose()?, + note: optional_text(payload.note), + external_reference: optional_text(payload.external_reference), + created_by: operator.clone(), + }; + input.validate()?; + Ok(input) + })(); + let mut input = match input { + Ok(v) => v, + Err(e) => return Ok(Some(bad_request(e))), + }; + let providers = state + .read_provider_catalog_providers_by_ids(&[input.provider_id.clone()]) + .await?; + let Some(provider) = providers.first() else { + return Ok(Some(not_found("Provider not found"))); + }; + input.provider_name = provider.name.clone(); + let result = state + .app() + .data + .create_provider_expense(&input) + .await + .map_err(|e| GatewayError::Internal(e.to_string()))?; + return Ok(Some(mutation_response( + result, + "admin_provider_expense_recorded", + "record_provider_expense", + ))); + } + if context.method() == http::Method::POST { + if let Some(id) = path + .strip_prefix("/api/admin/billing/provider-expenses/") + .and_then(|v| v.strip_suffix("/void")) + .filter(|v| !v.is_empty() && !v.contains('/')) + { + if uuid::Uuid::parse_str(id).is_err() { + return Ok(Some(bad_request("invalid expense id"))); + } + let result = state + .app() + .data + .void_provider_expense(id, operator.as_deref()) + .await + .map_err(|e| GatewayError::Internal(e.to_string()))?; + return Ok(Some(mutation_response( + result, + "admin_provider_expense_voided", + "void_provider_expense", + ))); + } + } + Ok(None) +} +fn mutation_response( + outcome: AdminBillingMutationOutcome, + event: &'static str, + action: &'static str, +) -> Response { + match outcome { + AdminBillingMutationOutcome::Applied(record) => attach_admin_audit_response( + Json(json!({"item":expense_json(&record)})).into_response(), + event, + action, + "provider_expense", + &record.id, + ), + AdminBillingMutationOutcome::Invalid(e) => conflict(e), + AdminBillingMutationOutcome::NotFound => not_found("Provider expense not found"), + AdminBillingMutationOutcome::Unavailable => unavailable(), + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn provider_expense_csv_neutralizes_formulas_and_quotes_fields() { + assert_eq!(csv_cell("=cmd()"), "\"'=cmd()\""); + assert_eq!(csv_cell(" @cmd"), "\"' @cmd\""); + assert_eq!(csv_cell("\tcmd"), "\"'\tcmd\""); + assert_eq!(csv_cell("a,\"b\"\nc"), "\"a,\"\"b\"\"\nc\""); + assert_eq!(csv_cell("12.34"), "\"12.34\""); + } + #[test] + fn provider_expense_dates_require_explicit_timezone_and_nonnegative_epoch() { + assert_eq!( + parse_date("2026-09-20T08:00:00+08:00"), + parse_date("2026-09-20T00:00:00Z") + ); + assert!(parse_date("2026-09-20").is_err()); + assert!(parse_date("1969-01-01T00:00:00Z").is_err()); + } +} diff --git a/apps/aether-gateway/src/handlers/admin/billing/wallets/reads/list.rs b/apps/aether-gateway/src/handlers/admin/billing/wallets/reads/list.rs index c8cda09a2..e85a0bdcb 100644 --- a/apps/aether-gateway/src/handlers/admin/billing/wallets/reads/list.rs +++ b/apps/aether-gateway/src/handlers/admin/billing/wallets/reads/list.rs @@ -27,11 +27,18 @@ pub(in super::super) async fn build_admin_wallet_list_response( Ok(value) => value, Err(detail) => return Ok(build_admin_wallets_bad_request_response(detail)), }; + let user_id = query_param_value(query, "user_id"); let status = query_param_value(query, "status"); let owner_type = parse_admin_wallets_owner_type_filter(query); let (wallets, total) = state - .list_admin_wallets(status.as_deref(), owner_type.as_deref(), limit, offset) + .list_admin_wallets( + user_id.as_deref(), + status.as_deref(), + owner_type.as_deref(), + limit, + offset, + ) .await?; let mut items = Vec::with_capacity(wallets.len()); for wallet in wallets { diff --git a/apps/aether-gateway/src/handlers/admin/endpoint/health.rs b/apps/aether-gateway/src/handlers/admin/endpoint/health.rs index 80b7a0c30..32da2527f 100644 --- a/apps/aether-gateway/src/handlers/admin/endpoint/health.rs +++ b/apps/aether-gateway/src/handlers/admin/endpoint/health.rs @@ -35,11 +35,39 @@ fn build_admin_endpoint_health_bad_request_response(detail: &str) -> Response, request_context: &AdminRequestContext<'_>, + request_body: Option<&axum::body::Bytes>, ) -> Result>, GatewayError> { let Some(decision) = request_context.decision() else { return Ok(None); }; + if decision.route_family.as_deref() == Some("endpoints_health") { + if decision.route_kind.as_deref() == Some("health_v2") { + return Ok(Some( + crate::handlers::shared::health_monitor::build_health_v2_response( + state.app(), + request_context.path(), + request_context.query_string(), + crate::handlers::shared::health_monitor::HealthAudience::Admin, + ) + .await, + )); + } + if decision.route_kind.as_deref() == Some("health_v2_publication") { + return Ok(Some( + crate::handlers::shared::health_monitor::build_publication_response( + state.app(), + if request_context.method() == http::Method::PUT { + Some(request_body.map_or(&[][..], |body| body.as_ref())) + } else { + None + }, + ) + .await, + )); + } + } + if decision.route_family.as_deref() == Some("endpoints_health") && decision.route_kind.as_deref() == Some("health_summary") && request_context.path() == "/api/admin/endpoints/health/summary" diff --git a/apps/aether-gateway/src/handlers/admin/endpoint/routes.rs b/apps/aether-gateway/src/handlers/admin/endpoint/routes.rs index 4c851e2ac..17221e5b3 100644 --- a/apps/aether-gateway/src/handlers/admin/endpoint/routes.rs +++ b/apps/aether-gateway/src/handlers/admin/endpoint/routes.rs @@ -8,6 +8,7 @@ pub(crate) async fn maybe_build_local_admin_endpoints_response( if let Some(response) = health::maybe_build_local_admin_endpoints_health_response( &request.state(), &request.request_context(), + request.request_body(), ) .await? { diff --git a/apps/aether-gateway/src/handlers/admin/observability/mod.rs b/apps/aether-gateway/src/handlers/admin/observability/mod.rs index 27904b9ff..c2bd1461c 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/mod.rs @@ -1,4 +1,5 @@ mod monitoring; +mod overview; mod routes; mod stats; mod usage; diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs index f2a29649f..e6ffb74a2 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/mod.rs @@ -22,6 +22,8 @@ pub(crate) mod test_support; mod trace; mod usage_helpers; +pub(super) use resilience::overview_resilience_payload; + pub(crate) async fn maybe_build_local_admin_monitoring_response( state: &AdminAppState<'_>, request_context: &AdminRequestContext<'_>, diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience/mod.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience/mod.rs index 62e02e26d..553cbe169 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience/mod.rs @@ -6,3 +6,23 @@ mod status; pub(super) use history::build_admin_monitoring_resilience_circuit_history_response; pub(super) use reset::build_admin_monitoring_reset_error_stats_response; pub(super) use status::build_admin_monitoring_resilience_status_response; + +pub(in super::super) async fn overview_resilience_payload( + state: &crate::handlers::admin::request::AdminAppState<'_>, +) -> Result { + let snapshot = snapshot::build_admin_monitoring_resilience_snapshot(state).await?; + let from = (snapshot.timestamp - chrono::Duration::hours(24)) + .timestamp() + .max( + state + .admin_monitoring_error_stats_reset_at() + .unwrap_or_default() as i64, + ); + Ok(serde_json::json!({ + "scope": {"kind": "installation"}, + "error_range": {"from": chrono::DateTime::from_timestamp(from, 0), "to": snapshot.timestamp}, + "timestamp": snapshot.timestamp, "health_score": snapshot.health_score, + "status": snapshot.status, "error_statistics": snapshot.error_statistics, + "recent_errors": snapshot.recent_errors, "recommendations": snapshot.recommendations, + })) +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/overview/dashboard.rs b/apps/aether-gateway/src/handlers/admin/observability/overview/dashboard.rs new file mode 100644 index 000000000..d92d09bcf --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/observability/overview/dashboard.rs @@ -0,0 +1,134 @@ +use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; +use crate::GatewayError; +use aether_admin::observability::analytics::{dashboard_value, parse_dashboard_query}; +use axum::{ + body::Body, + http::{self, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; + +pub(super) async fn response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, +) -> Result, GatewayError> { + let query = match parse_dashboard_query(context.query_string()) { + Ok(query) => query, + Err(detail) => return Ok(super::error(StatusCode::BAD_REQUEST, &detail)), + }; + if !state.as_ref().has_usage_data_reader() { + return Ok(super::error( + StatusCode::SERVICE_UNAVAILABLE, + "usage analytics is unavailable", + )); + } + let snapshot = match tokio::time::timeout( + std::time::Duration::from_secs(15), + state.as_ref().query_dashboard_analytics(&query), + ) + .await + { + Ok(result) => result?, + Err(_) => { + return Ok(super::error( + StatusCode::GATEWAY_TIMEOUT, + "dashboard query exceeded its time budget", + )) + } + }; + let data = dashboard_value(&query, &snapshot).map_err(GatewayError::Internal)?; + Ok(( + [(http::header::CACHE_CONTROL, "private, no-store")], + Json(data), + ) + .into_response()) +} + +pub(super) async fn total_response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, +) -> Result, GatewayError> { + use crate::cache::OverviewTotalRead; + use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery; + use serde_json::json; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + let query = match parse_dashboard_query(context.query_string()) { + Ok(query) => query, + Err(detail) => return Ok(super::error(StatusCode::BAD_REQUEST, &detail)), + }; + if !state.as_ref().has_usage_data_reader() { + return Ok(super::error( + StatusCode::SERVICE_UNAVAILABLE, + "usage analytics is unavailable", + )); + } + let (cached, refresh) = state.as_ref().overview_total_cache.read(Instant::now()); + if let Some(refresh) = refresh { + let app = state.as_ref(); + let data = if app.background_data.has_usage_reader() { + Arc::clone(&app.background_data) + } else { + Arc::clone(&app.data) + }; + // Lifetime boundaries do not depend on the viewer's timezone. Every + // administrator shares one refresh, including after a page reload. + tokio::spawn(async move { + let query = UsageDashboardAnalyticsQuery { + timezone: "UTC".into(), + }; + let result = tokio::time::timeout( + Duration::from_secs(185), + data.query_dashboard_analytics(&query), + ) + .await; + let snapshot = match result { + Ok(Ok(snapshot)) => Some(snapshot), + Ok(Err(error)) => { + tracing::warn!(%error, "dashboard lifetime refresh failed"); + None + } + Err(_) => { + tracing::warn!("dashboard lifetime refresh exceeded its time budget"); + None + } + }; + refresh.finish(snapshot, Instant::now()); + }); + } + let (status, body, retry_after) = match cached { + OverviewTotalRead::Pending => { + (StatusCode::ACCEPTED, json!({"status":"pending"}), Some("3")) + } + OverviewTotalRead::Failed => ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status":"failed", "detail":"cumulative dashboard totals are temporarily unavailable; retry shortly"}), + Some("10"), + ), + OverviewTotalRead::Ready { snapshot, stale } => { + let mut value = dashboard_value(&query, &snapshot).map_err(GatewayError::Internal)?; + ( + StatusCode::OK, + json!({ + "status":"ready", "total": value["total"].take(), + "history_complete": snapshot.history_complete, "stale": stale, + }), + None, + ) + } + }; + let mut response = ( + status, + [(http::header::CACHE_CONTROL, "private, no-store")], + Json(body), + ) + .into_response(); + if let Some(retry_after) = retry_after { + response.headers_mut().insert( + http::header::RETRY_AFTER, + http::HeaderValue::from_static(retry_after), + ); + } + Ok(response) +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/overview/dashboard_summary.rs b/apps/aether-gateway/src/handlers/admin/observability/overview/dashboard_summary.rs new file mode 100644 index 000000000..e2dddf2af --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/observability/overview/dashboard_summary.rs @@ -0,0 +1,46 @@ +use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; +use crate::GatewayError; +use aether_admin::observability::analytics::{dashboard_summary_value, parse_dashboard_query}; +use axum::{ + body::Body, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; + +pub(super) async fn response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, +) -> Result, GatewayError> { + let query = match parse_dashboard_query(context.query_string()) { + Ok(query) => query, + Err(detail) => return Ok(super::error(StatusCode::BAD_REQUEST, &detail)), + }; + if !state.as_ref().has_usage_data_reader() { + return Ok(super::error( + StatusCode::SERVICE_UNAVAILABLE, + "dashboard statistics are unavailable", + )); + } + let snapshot = match tokio::time::timeout( + std::time::Duration::from_secs(5), + state.as_ref().data.query_dashboard_summary(&query), + ) + .await + { + Ok(Ok(snapshot)) => snapshot, + Ok(Err(error)) => return Err(GatewayError::Internal(error.to_string())), + Err(_) => { + return Ok(super::error( + StatusCode::GATEWAY_TIMEOUT, + "dashboard statistics exceeded their time budget", + )) + } + }; + let mut value = dashboard_summary_value(&snapshot); + value["concurrency"] = state + .as_ref() + .today_concurrency(&query.timezone) + .map_err(GatewayError::Internal)?; + Ok(([(header::CACHE_CONTROL, "private, no-store")], Json(value)).into_response()) +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/overview/live.rs b/apps/aether-gateway/src/handlers/admin/observability/overview/live.rs new file mode 100644 index 000000000..b48ea6c28 --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/observability/overview/live.rs @@ -0,0 +1,144 @@ +use super::error; +use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; +use crate::GatewayError; +use aether_admin::observability::analytics::{envelope, metrics_value, OverviewRequest}; +use aether_data_contracts::repository::usage::{UsageAnalyticsQuery, USAGE_ANALYTICS_VERSION}; +use axum::{ + body::Body, + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +pub(super) async fn response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, +) -> Result, GatewayError> { + if context + .query_string() + .is_some_and(|query| !query.is_empty()) + { + return Ok(error( + StatusCode::BAD_REQUEST, + "live diagnostics do not accept historical filters", + )); + } + let app = state.as_ref(); + let _ = app.metric_samples().await; + let snapshot = app.metric_snapshot.read().await.clone(); + let captured = snapshot.as_ref().map(|(captured, _)| *captured); + let now = chrono::Utc::now(); + let observed_at = captured + .and_then(|captured| chrono::Duration::from_std(captured.elapsed()).ok()) + .map(|age| now - age); + let mut unavailable = Vec::new(); + let (resilience_result, recent_result) = tokio::join!( + tokio::time::timeout( + std::time::Duration::from_secs(3), + super::super::monitoring::overview_resilience_payload(state) + ), + tokio::time::timeout( + std::time::Duration::from_secs(3), + recent_activity(state, now) + ), + ); + let resilience = match resilience_result { + Ok(Ok(value)) => Some(value), + _ => { + tracing::warn!("overview resilience snapshot unavailable"); + unavailable.push("resilience"); + None + } + }; + let recent_activity = match recent_result { + Ok(Ok(value)) => Some(value), + _ => { + unavailable.push("recent_activity"); + None + } + }; + if captured.is_none() { + unavailable.push("metrics"); + } + let mut response = Json(json!({ + "meta": { + "schema_version": 1, "metric_version": USAGE_ANALYTICS_VERSION, "scope": {"kind": "node"}, + "generated_at": now, "data_through": observed_at, "read_revision": observed_at.map(|value| value.timestamp_millis().to_string()), + "coverage": {"status": if unavailable.is_empty() {"complete"} else {"partial"}}, + }, + "data": { + "observed_at": observed_at, "window_seconds": null, "node_id": null, + "scope": {"kind": "node", "node_ids": []}, + "metrics_text": snapshot.map(|(_, samples)| aether_runtime::metrics::render_prometheus_text(&samples)), + "resilience": resilience, "recent_activity": recent_activity, + "execution_activity": app.execution_activity.snapshot(), + "unavailable_sections": unavailable, + }, + })).into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + Ok(response) +} + +async fn recent_activity( + state: &AdminAppState<'_>, + now: chrono::DateTime, +) -> Result { + let to = now.timestamp_millis().max(60_000) as u64; + let request = OverviewRequest { + query: UsageAnalyticsQuery { + from_unix_ms: to - 60_000, + to_unix_ms: to, + timezone: "UTC".into(), + limit: 1, + ..Default::default() + }, + amount_basis: "billable".into(), + csv: false, + }; + let snapshot = state.as_ref().query_usage_analytics(&request.query).await?; + let data = recent_activity_data(&snapshot); + Ok(envelope(&request, &snapshot, data)) +} + +fn recent_activity_data( + snapshot: &aether_data_contracts::repository::usage::StoredUsageAnalytics, +) -> serde_json::Value { + let mut data = metrics_value(&snapshot.summary); + data["requests_per_second"] = json!(snapshot.summary.request_count as f64 / 60.0); + data["requests_per_minute"] = json!(snapshot.summary.request_count); + data["tokens_per_minute"] = data["total_tokens"].clone(); + data["window_seconds"] = json!(60); + data +} + +#[cfg(test)] +mod tests { + use super::*; + use aether_data_contracts::repository::usage::{StoredUsageAnalytics, UsageAnalyticsMetrics}; + + #[test] + fn recent_activity_reports_one_minute_rates_without_inventing_missing_tokens() { + let mut snapshot = StoredUsageAnalytics { + summary: UsageAnalyticsMetrics { + request_count: 120, + usage_available_count: 120, + total_tokens: 4200, + ..Default::default() + }, + ..Default::default() + }; + let value = recent_activity_data(&snapshot); + assert_eq!(value["window_seconds"], 60); + assert_eq!(value["requests_per_second"], 2.0); + assert_eq!(value["requests_per_minute"], 120); + assert_eq!(value["tokens_per_minute"], 4200); + snapshot.summary.usage_available_count = 0; + assert!(recent_activity_data(&snapshot)["tokens_per_minute"].is_null()); + snapshot.summary = UsageAnalyticsMetrics::default(); + assert_eq!(recent_activity_data(&snapshot)["tokens_per_minute"], 0); + } +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/overview/mod.rs b/apps/aether-gateway/src/handlers/admin/observability/overview/mod.rs new file mode 100644 index 000000000..f2ecd0301 --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/observability/overview/mod.rs @@ -0,0 +1,184 @@ +mod dashboard; +mod dashboard_summary; +mod live; + +use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; +use crate::GatewayError; +use aether_admin::observability::analytics::{ + costs_value, dashboard_charts_value, envelope, export_csv, metrics_value, page_value, + parse_dashboard_charts_query, parse_overview_query, performance_value, user_finance_value, + user_payments_value, +}; +use aether_data_contracts::repository::usage::{UsageAnalyticsGranularity, UsageAnalyticsView}; +use axum::{ + body::Body, + http::{self, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +pub(crate) async fn maybe_build_overview_response( + state: &AdminAppState<'_>, + context: &AdminRequestContext<'_>, +) -> Result>, GatewayError> { + if context.route_family() != Some("overview_manage") || context.method() != http::Method::GET { + return Ok(None); + } + let kind = context.route_kind().unwrap_or_default(); + if kind == "dashboard_summary" { + return dashboard_summary::response(state, context).await.map(Some); + } + if kind == "dashboard_total" { + return dashboard::total_response(state, context).await.map(Some); + } + if kind == "dashboard" { + return dashboard::response(state, context).await.map(Some); + } + if matches!(kind, "operations_live" | "operations_resources") { + return live::response(state, context).await.map(Some); + } + let view = match kind { + "dashboard_charts" => UsageAnalyticsView::DashboardCharts, + "summary" => UsageAnalyticsView::Summary, + "timeseries" | "costs" => UsageAnalyticsView::Timeseries, + "operations_performance" => UsageAnalyticsView::Performance, + "users" | "user_detail" => UsageAnalyticsView::Users, + "breakdown" => UsageAnalyticsView::Breakdown, + "consumption" => UsageAnalyticsView::Consumption, + _ => return Ok(None), + }; + let parsed = if kind == "dashboard_charts" { + parse_dashboard_charts_query(context.query_string()) + } else { + parse_overview_query(context.query_string(), view) + }; + let mut request = match parsed { + Ok(value) => value, + Err(detail) => return Ok(Some(error(StatusCode::BAD_REQUEST, &detail))), + }; + if kind == "user_detail" { + let encoded = context + .path() + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + let Ok(id) = percent_encoding::percent_decode_str(encoded).decode_utf8() else { + return Ok(Some(error( + StatusCode::BAD_REQUEST, + "invalid user identifier", + ))); + }; + let id = id.as_ref(); + if id.is_empty() || id.len() > 512 || id.contains('/') || id.chars().any(char::is_control) { + return Ok(Some(error( + StatusCode::BAD_REQUEST, + "invalid user identifier", + ))); + } + if request + .query + .actor_user_id + .as_deref() + .is_some_and(|value| value != id) + || request + .query + .credential_owner_id + .as_deref() + .is_some_and(|value| value != id) + { + return Ok(Some(error( + StatusCode::BAD_REQUEST, + "user filter conflicts with the requested employee", + ))); + } + if request.query.attribution_kind.as_deref() == Some("employee") { + request.query.actor_user_id = Some(id.into()); + } else { + request.query.credential_owner_id = Some(id.into()); + } + request.query.limit = 1; + request.query.offset = 0; + } + if matches!( + view, + UsageAnalyticsView::Timeseries | UsageAnalyticsView::Performance + ) { + request.query.limit = 10_000; + request.query.offset = 0; + } + if kind == "costs" { + request.query.granularity = UsageAnalyticsGranularity::Day; + } + if !state.as_ref().has_usage_data_reader() { + return Ok(Some(error( + StatusCode::SERVICE_UNAVAILABLE, + "usage analytics is unavailable", + ))); + } + let snapshot = match tokio::time::timeout( + std::time::Duration::from_secs(if request.csv { 30 } else { 15 }), + state.as_ref().query_usage_analytics(&request.query), + ) + .await + { + Ok(result) => result?, + Err(_) => { + return Ok(Some(error( + StatusCode::GATEWAY_TIMEOUT, + "report query exceeded its time budget; narrow the range or filters", + ))) + } + }; + if request.csv { + return Ok(Some(match export_csv(&request, &snapshot) { + Ok(csv) => ( + [ + (http::header::CONTENT_TYPE, "text/csv; charset=utf-8"), + ( + http::header::CONTENT_DISPOSITION, + "attachment; filename=overview.csv", + ), + (http::header::CACHE_CONTROL, "private, no-store"), + ], + csv, + ) + .into_response(), + Err(detail) => error(StatusCode::UNPROCESSABLE_ENTITY, &detail), + })); + } + let data = match kind { + "dashboard_charts" => dashboard_charts_value(&snapshot), + "summary" => metrics_value(&snapshot.summary), + "user_detail" => { + let Some(user) = snapshot.users.first() else { + return Ok(Some(error(StatusCode::NOT_FOUND, "employee not found"))); + }; + json!({ + "user": { "id": user.user_id, "username": user.username, "email": user.email, "is_active": user.is_active }, + "summary": metrics_value(&user.metrics), + "finance": user_finance_value(user.finance.as_ref()), + "payments": user_payments_value(snapshot.user_payments.as_ref()), + }) + } + "costs" => costs_value(&request, &snapshot), + "timeseries" => { + let mut page = page_value(&request, &snapshot); + page["granularity"] = json!(request.query.granularity); + page + } + "operations_performance" => performance_value(&request, &snapshot), + _ => page_value(&request, &snapshot), + }; + let mut response = Json(envelope(&request, &snapshot, data)).into_response(); + response.headers_mut().insert( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("private, no-store"), + ); + Ok(Some(response)) +} + +fn error(status: StatusCode, detail: &str) -> Response { + (status, Json(json!({"detail": detail}))).into_response() +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/routes.rs b/apps/aether-gateway/src/handlers/admin/observability/routes.rs index b7c7e5e8e..63413d0ac 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/routes.rs @@ -1,9 +1,15 @@ -use super::{monitoring, stats, usage}; +use super::{monitoring, overview, stats, usage}; use crate::handlers::admin::request::{AdminRouteRequest, AdminRouteResult}; pub(crate) async fn maybe_build_local_admin_observability_response( request: AdminRouteRequest<'_>, ) -> AdminRouteResult { + if let Some(response) = + overview::maybe_build_overview_response(&request.state(), &request.request_context()) + .await? + { + return Ok(Some(response)); + } if let Some(response) = stats::maybe_build_local_admin_stats_response(&request.state(), &request.request_context()) .await? diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/analytics_routes/aggregation.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/analytics_routes/aggregation.rs index 039633d48..ca2ecef26 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/analytics_routes/aggregation.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/analytics_routes/aggregation.rs @@ -1,5 +1,5 @@ -use super::super::super::stats::resolve_admin_usage_time_range; use super::super::analytics::admin_usage_aggregation_by_user_json; +use super::super::summary_routes::resolve_record_time_bounds; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::shared::query_param_value; use crate::GatewayError; @@ -159,12 +159,11 @@ pub(super) async fn build_admin_usage_aggregation_stats_response( Ok(value) => value, Err(detail) => return Ok(admin_usage_bad_request_response(detail)), }; - let time_range = match resolve_admin_usage_time_range(query) { + let time_bounds = match resolve_record_time_bounds(query) { Ok(value) => value, Err(detail) => return Ok(admin_usage_bad_request_response(detail)), }; - let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds() - else { + let Some((created_from_unix_secs, created_until_unix_secs)) = time_bounds else { return Ok(Json(json!([])).into_response()); }; let group_by_query = match group_by.as_str() { diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs index 14a3f0d95..a197e8cce 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs @@ -33,6 +33,50 @@ use std::collections::{BTreeMap, BTreeSet}; const ADMIN_USAGE_ACTIVE_LIMIT: usize = 50; +pub(super) fn resolve_record_time_bounds( + query: Option<&str>, +) -> Result, String> { + let entries = + url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()).collect::>(); + let from = entries + .iter() + .filter(|(key, _)| key == "from") + .collect::>(); + let to = entries + .iter() + .filter(|(key, _)| key == "to") + .collect::>(); + if from.is_empty() && to.is_empty() { + return resolve_admin_usage_time_range(query).map(|range| range.to_unix_bounds()); + } + if from.len() != 1 || to.len() != 1 { + return Err("from and to must each be provided once".into()); + } + if entries + .iter() + .any(|(key, _)| matches!(key.as_ref(), "start_date" | "end_date" | "preset" | "days")) + { + return Err("precise from/to cannot be combined with date presets".into()); + } + if let Some(zone) = query_param_value(query, "timezone") { + zone.parse::() + .map_err(|_| "invalid timezone".to_string())?; + } + let parse = |value: &str| -> Result { + let value = chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| "from/to must be RFC 3339 timestamps".to_string())?; + if value.timestamp_subsec_nanos() != 0 { + return Err("request records support second-aligned ranges".into()); + } + u64::try_from(value.timestamp()).map_err(|_| "from/to must not precede Unix epoch".into()) + }; + let bounds = (parse(&from[0].1)?, parse(&to[0].1)?); + if bounds.0 >= bounds.1 || bounds.1 - bounds.0 > 366 * 86_400 { + return Err("from/to must define a nonempty range of at most 366 days".into()); + } + Ok(Some(bounds)) +} + async fn load_admin_usage_by_ids( state: &AdminAppState<'_>, requested_ids: &BTreeSet, @@ -70,6 +114,7 @@ fn apply_admin_usage_status_filter(query: &mut UsageAuditListQuery, status: Opti } "websocket" | "ws" => query.is_websocket = Some(true), "error" | "failed" => query.error_only = true, + "success" => query.statuses = Some(vec!["completed".to_string()]), "active" => { query.statuses = Some(vec!["pending".to_string(), "streaming".to_string()]); } @@ -523,12 +568,37 @@ fn build_admin_usage_records_query( query: Option<&str>, limit: Option, offset: Option, -) -> UsageAuditListQuery { +) -> Result { + let boolean = |key| match query_param_value(query, key).as_deref() { + None => Ok(None), + Some("true" | "1") => Ok(Some(true)), + Some("false" | "0") => Ok(Some(false)), + Some(_) => Err(format!("invalid {key}: expected true or false")), + }; + let slow_threshold_ms = query_param_value(query, "slow_threshold_ms") + .map(|value| { + value + .parse::() + .ok() + .filter(|value| (1..=86_400_000).contains(value)) + .ok_or_else(|| "slow_threshold_ms must be between 1 and 86400000".to_string()) + }) + .transpose()?; let mut list_query = UsageAuditListQuery { created_from_unix_secs: Some(created_from_unix_secs), created_until_unix_secs: Some(created_until_unix_secs), user_id: query_param_value(query, "user_id"), provider_name: query_param_value(query, "provider"), + provider_id: query_param_value(query, "provider_id"), + api_key_id: query_param_value(query, "api_key_id"), + request_id: query_param_value(query, "request_id"), + attribution_kind: query_param_value(query, "attribution_kind"), + actor_user_id: query_param_value(query, "actor_user_id"), + slow_threshold_ms, + endpoint_kind: query_param_value(query, "endpoint_kind"), + request_type: query_param_value(query, "request_type"), + has_format_conversion: boolean("has_format_conversion")?, + is_stream: boolean("is_stream")?, model: query_param_value(query, "model"), api_format: query_param_value(query, "api_format"), limit, @@ -536,11 +606,23 @@ fn build_admin_usage_records_query( newest_first: true, ..Default::default() }; + if list_query + .attribution_kind + .as_deref() + .is_some_and(|kind| !matches!(kind, "employee" | "standalone" | "unknown")) + { + return Err("invalid attribution_kind".into()); + } + if list_query.attribution_kind.as_deref() == Some("employee") + && list_query.actor_user_id.is_none() + { + list_query.actor_user_id = list_query.user_id.take(); + } apply_admin_usage_status_filter( &mut list_query, query_param_value(query, "status").as_deref(), ); - list_query + Ok(list_query) } fn parse_admin_usage_search_keywords(search: &str) -> Vec { @@ -653,6 +735,15 @@ fn build_admin_usage_keyword_search_query( created_until_unix_secs: base_query.created_until_unix_secs, user_id: base_query.user_id.clone(), provider_name: base_query.provider_name.clone(), + provider_id: base_query.provider_id.clone(), + api_key_id: base_query.api_key_id.clone(), + request_id: base_query.request_id.clone(), + attribution_kind: base_query.attribution_kind.clone(), + actor_user_id: base_query.actor_user_id.clone(), + slow_threshold_ms: base_query.slow_threshold_ms, + endpoint_kind: base_query.endpoint_kind.clone(), + request_type: base_query.request_type.clone(), + has_format_conversion: base_query.has_format_conversion, model: base_query.model.clone(), api_format: base_query.api_format.clone(), client_family: base_query.client_family.clone(), @@ -699,13 +790,11 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( } let query = request_context.request_query_string.as_deref(); - let time_range = match resolve_admin_usage_time_range(query) { + let time_bounds = match resolve_record_time_bounds(query) { Ok(value) => value, Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), }; - let Some((created_from_unix_secs, created_until_unix_secs)) = - time_range.to_unix_bounds() - else { + let Some((created_from_unix_secs, created_until_unix_secs)) = time_bounds else { return Ok(Some(build_admin_usage_summary_stats_response_from_summary( &Default::default(), ))); @@ -743,13 +832,11 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( sort_usage_newest_first(&mut items); items } else { - let time_range = match resolve_admin_usage_time_range(query) { + let time_bounds = match resolve_record_time_bounds(query) { Ok(value) => value, Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), }; - let Some((created_from_unix_secs, created_until_unix_secs)) = - time_range.to_unix_bounds() - else { + let Some((created_from_unix_secs, created_until_unix_secs)) = time_bounds else { return Ok(Some(build_admin_usage_active_requests_response( &[], &BTreeMap::new(), @@ -806,7 +893,7 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( } let query = request_context.request_query_string.as_deref(); - let time_range = match resolve_admin_usage_time_range(query) { + let time_bounds = match resolve_record_time_bounds(query) { Ok(value) => value, Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), }; @@ -827,9 +914,7 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( Ok(value) => value, Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), }; - let Some((created_from_unix_secs, created_until_unix_secs)) = - time_range.to_unix_bounds() - else { + let Some((created_from_unix_secs, created_until_unix_secs)) = time_bounds else { return Ok(Some(build_admin_usage_records_response( &[], &BTreeMap::new(), @@ -849,13 +934,16 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( let active_client_family_filter = client_family_filter .as_deref() .filter(|value| !value.trim().is_empty()); - let mut base_query = build_admin_usage_records_query( + let mut base_query = match build_admin_usage_records_query( created_from_unix_secs, created_until_unix_secs, query, None, None, - ); + ) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), + }; base_query.client_family = active_client_family_filter.map(str::to_owned); base_query.exclude_unknown_model_or_provider = hide_unknown_records; let (usage, total, total_is_estimated) = if attempt_status_filter.is_some() { @@ -1028,6 +1116,89 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( #[cfg(test)] mod tests { + #[test] + fn precise_record_ranges_preserve_minutes_and_reject_mixed_presets() { + let range = "from=2026-09-01T23:45:00Z&to=2026-09-02T00:15:00Z&timezone=Asia%2FShanghai"; + let (from, to) = super::resolve_record_time_bounds(Some(range)) + .unwrap() + .unwrap(); + assert_eq!(to - from, 30 * 60); + assert!(super::resolve_record_time_bounds(Some(&format!("{range}&preset=today"))).is_err()); + assert!(super::resolve_record_time_bounds(Some("from=2026-09-01T00:00:00Z")).is_err()); + } + + #[test] + fn overview_record_drilldown_preserves_actor_and_performance_filters() { + let raw = "user_id=employee-1&attribution_kind=employee&provider_id=provider-1&api_key_id=key-1&request_id=request-1&endpoint_kind=chat&request_type=chat&is_stream=true&has_format_conversion=false&slow_threshold_ms=12000&status=success"; + let query = + super::build_admin_usage_records_query(100, 200, Some(raw), None, None).unwrap(); + assert_eq!(query.user_id, None); + assert_eq!(query.actor_user_id.as_deref(), Some("employee-1")); + assert_eq!(query.provider_id.as_deref(), Some("provider-1")); + assert_eq!(query.api_key_id.as_deref(), Some("key-1")); + assert_eq!(query.request_id.as_deref(), Some("request-1")); + assert_eq!(query.slow_threshold_ms, Some(12_000)); + assert_eq!(query.is_stream, Some(true)); + assert_eq!(query.has_format_conversion, Some(false)); + assert_eq!(query.statuses, Some(vec!["completed".into()])); + let keyword = super::build_admin_usage_keyword_search_query( + &query, + vec!["example".into()], + None, + Default::default(), + false, + false, + None, + None, + ); + assert_eq!(keyword.actor_user_id, query.actor_user_id); + assert_eq!(keyword.slow_threshold_ms, query.slow_threshold_ms); + assert_eq!(keyword.has_format_conversion, query.has_format_conversion); + for invalid in [ + "is_stream=maybe", + "slow_threshold_ms=0", + "attribution_kind=owner", + ] { + assert!( + super::build_admin_usage_records_query(100, 200, Some(invalid), None, None) + .is_err() + ); + } + } + + #[test] + fn overview_record_drilldown_preserves_standalone_key_ownership() { + let raw = "user_id=owner-1&attribution_kind=standalone&api_key_id=standalone-key"; + let query = + super::build_admin_usage_records_query(100, 200, Some(raw), None, None).unwrap(); + assert_eq!(query.attribution_kind.as_deref(), Some("standalone")); + assert_eq!(query.user_id.as_deref(), Some("owner-1")); + assert_eq!(query.actor_user_id, None); + assert_eq!(query.api_key_id.as_deref(), Some("standalone-key")); + let keyword = super::build_admin_usage_keyword_search_query( + &query, + vec!["example".into()], + None, + Default::default(), + false, + false, + None, + None, + ); + assert_eq!(keyword.attribution_kind, query.attribution_kind); + assert_eq!(keyword.user_id, query.user_id); + assert_eq!(keyword.actor_user_id, query.actor_user_id); + assert_eq!(keyword.api_key_id, query.api_key_id); + + for retired_kind in ["service", "shared"] { + let raw = format!("attribution_kind={retired_kind}"); + assert!( + super::build_admin_usage_records_query(100, 200, Some(&raw), None, None).is_err(), + "{retired_kind}" + ); + } + } + use aether_data_contracts::repository::candidates::{ RequestCandidateStatus, StoredRequestCandidate, }; @@ -1169,7 +1340,7 @@ mod tests { for status in ["websocket", "ws", "WS"] { let raw_query = format!("status={status}"); let list_query = - build_admin_usage_records_query(100, 200, Some(&raw_query), None, None); + build_admin_usage_records_query(100, 200, Some(&raw_query), None, None).unwrap(); assert_eq!(list_query.is_websocket, Some(true)); assert_eq!(list_query.is_stream, None); @@ -1190,7 +1361,7 @@ mod tests { for (status, expected_stream) in [("stream", true), ("standard", false)] { let raw_query = format!("status={status}"); let list_query = - build_admin_usage_records_query(100, 200, Some(&raw_query), None, None); + build_admin_usage_records_query(100, 200, Some(&raw_query), None, None).unwrap(); assert_eq!(list_query.is_stream, Some(expected_stream)); assert_eq!(list_query.is_websocket, Some(false)); diff --git a/apps/aether-gateway/src/handlers/admin/request/billing.rs b/apps/aether-gateway/src/handlers/admin/request/billing.rs index 3ecde6481..116369798 100644 --- a/apps/aether-gateway/src/handlers/admin/request/billing.rs +++ b/apps/aether-gateway/src/handlers/admin/request/billing.rs @@ -121,6 +121,7 @@ impl<'a> AdminAppState<'a> { pub(crate) async fn list_admin_wallets( &self, + user_id: Option<&str>, status: Option<&str>, owner_type: Option<&str>, limit: usize, @@ -133,7 +134,7 @@ impl<'a> AdminAppState<'a> { GatewayError, > { self.app - .list_admin_wallets(status, owner_type, limit, offset) + .list_admin_wallets(user_id, status, owner_type, limit, offset) .await } diff --git a/apps/aether-gateway/src/handlers/admin/users/billing.rs b/apps/aether-gateway/src/handlers/admin/users/billing.rs index b30cbcd19..ee74b804a 100644 --- a/apps/aether-gateway/src/handlers/admin/users/billing.rs +++ b/apps/aether-gateway/src/handlers/admin/users/billing.rs @@ -1,7 +1,9 @@ use super::{build_admin_users_bad_request_response, build_admin_users_data_unavailable_response}; use crate::handlers::admin::billing::admin_payment_gateway_response_projection; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; -use crate::handlers::admin::shared::{attach_admin_audit_response, unix_secs_to_rfc3339}; +use crate::handlers::admin::shared::{ + attach_admin_audit_response, query_param_value, unix_secs_to_rfc3339, +}; use crate::handlers::shared::unix_ms_to_rfc3339; use crate::GatewayError; use aether_data_contracts::repository::billing::{BillingPlanRecord, UserPlanEntitlementRecord}; @@ -177,8 +179,13 @@ fn entitlement_payload( async fn load_admin_user_entitlements_payload( state: &AdminAppState<'_>, user_id: &str, + include_inactive: bool, ) -> Result, GatewayError> { - let entitlements = match state.app().list_user_plan_entitlements(user_id).await? { + let entitlements = match state + .app() + .list_user_plan_entitlements_with_history(user_id, include_inactive) + .await? + { Some(value) => value, None => return Ok(None), }; @@ -214,7 +221,17 @@ pub(in super::super) async fn build_admin_list_user_billing_entitlements_respons ) .into_response()); } - match load_admin_user_entitlements_payload(state, &user_id).await? { + let include_inactive = + match query_param_value(request_context.query_string(), "include_inactive").as_deref() { + None | Some("false" | "0") => false, + Some("true" | "1") => true, + _ => { + return Ok(build_admin_users_bad_request_response( + "include_inactive 必须为布尔值", + )) + } + }; + match load_admin_user_entitlements_payload(state, &user_id, include_inactive).await? { Some(payload) => Ok(Json(payload).into_response()), None => Ok(build_admin_users_data_unavailable_response()), } @@ -256,7 +273,7 @@ pub(in super::super) async fn build_admin_revoke_user_billing_entitlement_respon return Ok(build_admin_users_data_unavailable_response()); } } - let entitlements = match load_admin_user_entitlements_payload(state, &user_id).await? { + let entitlements = match load_admin_user_entitlements_payload(state, &user_id, false).await? { Some(value) => value, None => return Ok(build_admin_users_data_unavailable_response()), }; @@ -401,7 +418,7 @@ pub(in super::super) async fn build_admin_grant_user_billing_plan_response( return Ok(build_admin_users_data_unavailable_response()); } }; - let entitlements = match load_admin_user_entitlements_payload(state, &user_id).await? { + let entitlements = match load_admin_user_entitlements_payload(state, &user_id, false).await? { Some(value) => value, None => return Ok(build_admin_users_data_unavailable_response()), }; diff --git a/apps/aether-gateway/src/handlers/proxy/mod.rs b/apps/aether-gateway/src/handlers/proxy/mod.rs index 45e7e7d3c..f678e6d15 100644 --- a/apps/aether-gateway/src/handlers/proxy/mod.rs +++ b/apps/aether-gateway/src/handlers/proxy/mod.rs @@ -1996,9 +1996,22 @@ async fn proxy_request_inner( request_permit = aether_runtime::AdmissionPermit::combine( request_permit.into_iter().chain(plan_usage_permit), ); - if let Some(request_permit) = request_permit.as_ref() { + // The affinity-forwarding node already returned above. Observe only local + // AI execution, retaining the guard in both the body and detached execution. + let activity_permit = control_decision + .is_some_and(|decision| { + decision.route_class.as_deref() == Some("ai_public") + && decision.execution_runtime_candidate + }) + .then(|| state.request_activity.begin().into_admission_permit()); + if let Some(activity) = activity_permit.as_ref() { + crate::request_lifecycle::track_request_activity(activity.clone()); + } + if let Some(background_permit) = aether_runtime::AdmissionPermit::combine( + request_permit.clone().into_iter().chain(activity_permit), + ) { parts.extensions.insert( - crate::executor::candidate_loop::BackgroundAdmissionPermit::new(request_permit.clone()), + crate::executor::candidate_loop::BackgroundAdmissionPermit::new(background_permit), ); } diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/live/http.rs b/apps/aether-gateway/src/handlers/proxy/websocket/live/http.rs index 45b71b063..096dc948e 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/live/http.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/live/http.rs @@ -376,6 +376,12 @@ async fn handle_live_http( state, &attempt.plan, request_context.trace_id.as_str(), + Some( + attempt + .report_context + .as_ref() + .unwrap_or(&serde_json::Value::Null), + ), ) .await { diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/live/session.rs b/apps/aether-gateway/src/handlers/proxy/websocket/live/session.rs index 3f65bc299..fd5507652 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/live/session.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/live/session.rs @@ -1140,8 +1140,13 @@ async fn acquire_live_relay_admission( }); } } - match ResponsesWebSocketTurnAdmission::acquire(state, &attempt.plan, context.trace_id.as_str()) - .await + match ResponsesWebSocketTurnAdmission::acquire( + state, + &attempt.plan, + context.trace_id.as_str(), + None, + ) + .await { Ok(capacity) => Ok(LiveRelayAdmission { capacity, audit }), Err(error) => Err(LiveRelayAdmissionFailure { diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/realtime/session.rs b/apps/aether-gateway/src/handlers/proxy/websocket/realtime/session.rs index db40505ce..0c2dc1ee0 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/realtime/session.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/realtime/session.rs @@ -136,6 +136,7 @@ pub(super) async fn prepare_realtime_websocket( state, &candidate.admission_plan, context.trace_id.as_str(), + None, ) .await { diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs index 31f1e4e73..3a6570843 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs @@ -11,7 +11,8 @@ use aether_contracts::ExecutionPlan; use crate::execution_runtime::acquire_upstream_execution_gate; use crate::provider_pool_demand::{ - acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission, ProviderPoolInFlightGuard, + acquire_provider_pool_execution_guard, acquire_provider_pool_execution_guard_unobserved, + ProviderPoolInFlightAdmission, ProviderPoolInFlightGuard, }; use crate::upstream_admission::UpstreamTargetAdmissionPermit; use crate::{AppState, GatewayError}; @@ -28,6 +29,7 @@ impl ResponsesWebSocketTurnAdmission { state: &AppState, plan: &ExecutionPlan, trace_id: &str, + observation_context: Option<&serde_json::Value>, ) -> Result { let upstream_execution = acquire_upstream_execution_gate(state, trace_id).await?; let upstream_target = match state @@ -41,7 +43,13 @@ impl ResponsesWebSocketTurnAdmission { return Err(error); } }; - let provider_pool = match acquire_provider_pool_execution_guard(state, plan).await? { + let provider_admission = match observation_context { + Some(context) => { + acquire_provider_pool_execution_guard(state, plan, Some(context)).await? + } + None => acquire_provider_pool_execution_guard_unobserved(state, plan).await?, + }; + let provider_pool = match provider_admission { ProviderPoolInFlightAdmission::Acquired(guard) => guard, ProviderPoolInFlightAdmission::Saturated { limit } => { drop(upstream_target); diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/control.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/control.rs index ff9a994a3..830ed192b 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/control.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/control.rs @@ -32,6 +32,8 @@ pub(super) struct ResponsesWebSocketTurnControl { pub(super) decision: GatewayControlDecision, pub(super) auth_snapshot: Option, pub(super) rpm_bypassed: bool, + // Shared across transparent retries and owned by LogicalTurn, not the socket. + pub(super) activity: std::sync::Arc, } pub(super) async fn resolve_responses_websocket_turn_control( @@ -124,6 +126,7 @@ pub(super) async fn resolve_responses_websocket_turn_control( decision, auth_snapshot, rpm_bypassed, + activity: std::sync::Arc::new(state.request_activity.begin()), }) } diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs index 34b73d12b..55155b4fc 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs @@ -606,6 +606,7 @@ pub(super) async fn begin_unowned_responses_websocket_turn( state, &plan, plan.request_id.as_str(), + Some(report_context.as_ref().unwrap_or(&Value::Null)), ) .await { diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn_state.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn_state.rs index 07f08f64f..dc8d9d914 100644 --- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn_state.rs +++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/turn_state.rs @@ -260,6 +260,52 @@ mod tests { ) } + #[test] + fn concurrency_activity_survives_retry_and_ends_with_the_logical_turn() { + let activity = std::sync::Arc::new(crate::request_activity::RequestActivity::default()); + let first = logical().with_turn_control(super::ResponsesWebSocketTurnControl { + decision: crate::control::GatewayControlDecision::synthetic( + "/v1/responses", + Some("ai_public".into()), + None, + None, + None, + ), + auth_snapshot: None, + rpm_bypassed: false, + activity: std::sync::Arc::new(activity.begin()), + }); + let mut state = ResponsesTurnState::Idle; + state.begin(first, FakeAttempt(1)); + assert_eq!(activity.active(), 1); + assert_eq!(state.detach_attempt(), Some(FakeAttempt(1))); + assert_eq!( + activity.active(), + 1, + "transparent retry retains one logical request" + ); + state.resume(FakeAttempt(2)).unwrap(); + assert_eq!(activity.active(), 1); + assert_eq!(state.end(), Some(FakeAttempt(2))); + assert_eq!(activity.active(), 0); + + let logical = logical().with_turn_control(super::ResponsesWebSocketTurnControl { + decision: crate::control::GatewayControlDecision::synthetic( + "/v1/responses", + Some("ai_public".into()), + None, + None, + None, + ), + auth_snapshot: None, + rpm_bypassed: false, + activity: std::sync::Arc::new(activity.begin()), + }); + state.begin(logical, FakeAttempt(3)); + drop(state); + assert_eq!(activity.active(), 0, "disconnect drops the logical request"); + } + /// 透明重试失败之后:旧 attempt 已经被 detach 并结算过,logical turn 仍停在 /// `Replanning`。此时 `end()` 不能再交出 attempt,否则同一个 attempt 会被 /// 结算两次(两条 usage terminal、两次 pool lease 释放)。 diff --git a/apps/aether-gateway/src/handlers/public/support.rs b/apps/aether-gateway/src/handlers/public/support.rs index 19eef8c76..86536e061 100644 --- a/apps/aether-gateway/src/handlers/public/support.rs +++ b/apps/aether-gateway/src/handlers/public/support.rs @@ -166,6 +166,23 @@ async fn build_local_public_support_response( return None; } + if decision.route_family.as_deref() == Some("health_user") { + if let Err(response) = + resolve_authenticated_local_user(state, request_context, headers).await + { + return Some(response); + } + return Some( + crate::handlers::shared::health_monitor::build_health_v2_response( + state, + &request_context.request_path, + request_context.request_query_string.as_deref(), + crate::handlers::shared::health_monitor::HealthAudience::Authenticated, + ) + .await, + ); + } + if decision.route_family.as_deref() == Some("auth") { return maybe_build_local_auth_response( state, @@ -306,6 +323,17 @@ async fn build_local_public_support_response( } if decision.route_family.as_deref() == Some("public_catalog") { + if decision.route_kind.as_deref() == Some("health_v2") { + return Some( + crate::handlers::shared::health_monitor::build_health_v2_response( + state, + &request_context.request_path, + request_context.request_query_string.as_deref(), + crate::handlers::shared::health_monitor::HealthAudience::Public, + ) + .await, + ); + } if decision.route_kind.as_deref() == Some("site_info") && request_context.request_path == "/api/public/site-info" { diff --git a/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs b/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs index 07daf760d..d1ea701c3 100644 --- a/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs +++ b/apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs @@ -1,3 +1,4 @@ +use aether_data::repository::announcements::UserAnnouncementListQuery; use axum::{ body::Body, http, @@ -33,6 +34,40 @@ fn parse_announcement_read_status_request( } } +fn parse_user_announcements_query( + raw: Option<&str>, + now_unix_secs: u64, +) -> Result { + let mut query = UserAnnouncementListQuery { + unread_only: false, + offset: 0, + limit: 20, + now_unix_secs, + }; + let mut seen = std::collections::BTreeSet::new(); + for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + if !seen.insert(key.clone()) { + return Err(format!("duplicate announcement query parameter: {key}")); + } + match key.as_ref() { + "limit" => { + query.limit = value.parse().map_err(|_| "invalid announcement limit")?; + } + "offset" => { + query.offset = value.parse().map_err(|_| "invalid announcement offset")?; + } + "unread_only" => { + query.unread_only = value + .parse() + .map_err(|_| "unread_only must be true or false")?; + } + _ => return Err(format!("unsupported announcement query parameter: {key}")), + } + } + query.validate().map_err(|err| err.to_string())?; + Ok(query) +} + pub(crate) async fn maybe_build_local_announcement_user_response( state: &AppState, request_context: &GatewayPublicRequestContext, @@ -54,6 +89,48 @@ pub(crate) async fn maybe_build_local_announcement_user_response( let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64; match decision.route_kind.as_deref() { + Some("list") + if request_context.request_method == http::Method::GET + && matches!( + request_context.request_path.as_str(), + "/api/announcements/users/me" | "/api/announcements/users/me/" + ) => + { + let query = match parse_user_announcements_query( + request_context.request_query_string.as_deref(), + now_unix_secs, + ) { + Ok(query) => query, + Err(detail) => return Some(announcements_bad_request_response(detail)), + }; + let page = match state.list_user_announcements(&auth.user.id, &query).await { + Ok(page) => page, + Err(err) => { + return Some(announcements_internal_error_response( + announcements_internal_detail(err), + )) + } + }; + let items = page + .items + .iter() + .map(|item| { + let mut value = build_public_announcement_payload(&item.announcement); + value["is_read"] = json!(item.is_read); + value + }) + .collect::>(); + Some( + Json(json!({ + "items": items, + "total": page.total, + "unread_count": page.unread_count, + "limit": query.limit, + "offset": query.offset, + })) + .into_response(), + ) + } Some("unread_count") if request_context.request_method == http::Method::GET && matches!( diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs index 5052254bc..da54e73a8 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs @@ -1209,6 +1209,7 @@ pub(super) async fn handle_users_me_usage_get( limit: None, offset: None, newest_first: true, + ..Default::default() }; total_record_count = match state .count_usage_audits_by_keyword_search(&keyword_query) @@ -1259,6 +1260,7 @@ pub(super) async fn handle_users_me_usage_get( limit: None, offset: None, newest_first: true, + ..Default::default() }) .await { @@ -1289,6 +1291,7 @@ pub(super) async fn handle_users_me_usage_get( limit: Some(limit), offset: Some(offset), newest_first: true, + ..Default::default() }) .await { @@ -1434,6 +1437,7 @@ pub(super) async fn handle_users_me_usage_active_get( limit: Some(50), offset: None, newest_first: true, + ..Default::default() }) .await { diff --git a/apps/aether-gateway/src/handlers/shared/health_monitor/api.rs b/apps/aether-gateway/src/handlers/shared/health_monitor/api.rs new file mode 100644 index 000000000..3cbabe47b --- /dev/null +++ b/apps/aether-gateway/src/handlers/shared/health_monitor/api.rs @@ -0,0 +1,511 @@ +use super::policy::{overall_status, HealthPolicy, HealthRatio, HealthStatus}; +use super::publication::{HealthPublication, PUBLICATION_KEY}; +use crate::handlers::shared::unix_ms_to_rfc3339; +use crate::{AppState, GatewayError}; +use aether_data_contracts::repository::global_models::AdminGlobalModelListQuery; +use aether_data_contracts::repository::usage::{ + HealthObservationMetrics, HealthObservationObjectKind, HealthObservationQuery, +}; +use axum::{body::Body, http::StatusCode, response::IntoResponse, response::Response, Json}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chrono::Utc; +use serde::Serialize; +use serde_json::json; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum HealthAudience { + Admin, + Authenticated, + Public, +} + +#[derive(Clone, Debug)] +pub(super) struct HealthRequest { + pub kind: HealthObservationObjectKind, + pub from_unix_ms: u64, + pub to_unix_ms: u64, + pub limit: usize, + pub offset: usize, +} + +impl HealthRequest { + pub fn parse(query: Option<&str>, public: bool, now_ms: u64) -> Result { + let mut params = BTreeMap::new(); + for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) { + if !matches!(key.as_ref(), "kind" | "window" | "limit" | "offset") { + return Err("Unsupported health query parameter"); + } + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err("Duplicate health query parameter"); + } + } + let kind = match params + .get("kind") + .map(String::as_str) + .unwrap_or("api_format") + { + "api_format" => HealthObservationObjectKind::ApiFormat, + "model" => HealthObservationObjectKind::Model, + "provider" if !public => HealthObservationObjectKind::Provider, + _ => return Err("Unsupported health object kind"), + }; + let hours = match params.get("window").map(String::as_str).unwrap_or("6h") { + "1h" => 1, + "6h" => 6, + "24h" => 24, + "72h" => 72, + _ => return Err("Health window must be 1h, 6h, 24h or 72h"), + }; + let parse_size = |name: &str, default: usize| -> Result { + params + .get(name) + .map(|value| value.parse().map_err(|_| "Invalid pagination value")) + .unwrap_or(Ok(default)) + }; + let limit = parse_size("limit", 25)?; + let offset = parse_size("offset", 0)?; + if !(1..=100).contains(&limit) || offset > 10_000 { + return Err("Health pagination exceeds the supported range"); + } + Ok(Self { + kind, + from_unix_ms: now_ms.saturating_sub(hours * 3_600_000), + to_unix_ms: now_ms, + limit, + offset, + }) + } +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct HealthCoverage { + pub status: &'static str, + pub sample_status: &'static str, + pub classified_count: u64, + pub unknown_failure_count: u64, + pub excluded_count: u64, + pub exclusion_policy: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct PublicHealthObject { + pub id: String, + pub kind: HealthObservationObjectKind, + pub name: String, + pub status: HealthStatus, + pub request_count: u64, + pub request_success: HealthRatio, + pub service_availability: HealthRatio, + pub coverage: HealthCoverage, + pub average_latency_ms: Option, + pub latency_sample_count: u64, + pub last_request_at: Option, + pub timeline: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct PublicHealthBucket { + pub from: String, + pub to: String, + pub status: HealthStatus, + pub service_availability: HealthRatio, + pub unknown_failure_count: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct AdminHealthObject { + #[serde(flatten)] + pub service: PublicHealthObject, + pub source_value: String, + pub attempts: AdminAttemptMetrics, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct AdminAttemptMetrics { + pub succeeded_count: u64, + pub failed_count: u64, + pub in_progress_count: u64, + pub cancelled_count: u64, + pub success: HealthRatio, +} + +pub(super) fn public_projection( + id: String, + kind: HealthObservationObjectKind, + name: String, + metrics: &HealthObservationMetrics, + policy: &HealthPolicy, +) -> PublicHealthObject { + let classified = metrics + .service_succeeded_count + .saturating_add(metrics.service_failed_count); + PublicHealthObject { + id, + kind, + name, + status: policy.status( + metrics.service_succeeded_count, + metrics.service_failed_count, + metrics.unknown_failure_count, + ), + request_count: metrics.request_count, + request_success: HealthRatio::new( + metrics.succeeded_count, + metrics + .succeeded_count + .saturating_add(metrics.failed_count) + .saturating_add(metrics.cancelled_count), + ), + service_availability: HealthRatio::new(metrics.service_succeeded_count, classified), + coverage: HealthCoverage { + status: if metrics.unknown_failure_count > 0 { + "partial" + } else { + "complete" + }, + sample_status: if classified == 0 { + "empty" + } else if classified < policy.minimum_samples { + "insufficient" + } else { + "sufficient" + }, + classified_count: classified, + unknown_failure_count: metrics.unknown_failure_count, + excluded_count: metrics.excluded_count, + exclusion_policy: "client_cancelled_invalid_input_identity_or_quota_policy", + }, + average_latency_ms: (metrics.latency_sample_count > 0) + .then(|| metrics.latency_sum_ms as f64 / metrics.latency_sample_count as f64), + latency_sample_count: metrics.latency_sample_count, + last_request_at: metrics.last_request_at_unix_ms.and_then(unix_ms_to_rfc3339), + timeline: Vec::new(), + } +} + +fn response_error(status: StatusCode, detail: &str) -> Response { + (status, Json(json!({ "detail": detail }))).into_response() +} + +async fn read_publication(state: &AppState) -> Result { + let config = match state + .read_system_config_json_value_strong(PUBLICATION_KEY) + .await? + { + Some(value) => serde_json::from_value(value).map_err(|error| { + GatewayError::Internal(format!("Invalid health publication configuration: {error}")) + })?, + None => HealthPublication::default(), + }; + config + .validate() + .map_err(|error| GatewayError::Internal(error.to_string()))?; + Ok(config) +} + +pub(crate) async fn build_publication_response( + state: &AppState, + body: Option<&[u8]>, +) -> Response { + if let Some(body) = body { + let config: HealthPublication = match serde_json::from_slice(body) { + Ok(config) => config, + Err(_) => { + return response_error( + StatusCode::BAD_REQUEST, + "Invalid health publication configuration", + ) + } + }; + if let Err(error) = config.validate() { + return response_error(StatusCode::BAD_REQUEST, error); + } + let value = match serde_json::to_value(&config) { + Ok(value) => value, + Err(_) => { + return response_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Could not encode health publication", + ) + } + }; + return match state + .upsert_system_config_json_value( + PUBLICATION_KEY, + &value, + Some("Public status object allowlist"), + ) + .await + { + Ok(_) => Json(config).into_response(), + Err(_) => response_error( + StatusCode::SERVICE_UNAVAILABLE, + "Health publication is unavailable", + ), + }; + } + match read_publication(state).await { + Ok(config) => Json(config).into_response(), + Err(_) => response_error( + StatusCode::SERVICE_UNAVAILABLE, + "Health publication is unavailable", + ), + } +} + +pub(crate) async fn build_health_v2_response( + state: &AppState, + path: &str, + query: Option<&str>, + audience: HealthAudience, +) -> Response { + let now_ms = Utc::now().timestamp_millis().max(0) as u64; + let request = match HealthRequest::parse(query, audience != HealthAudience::Admin, now_ms) { + Ok(request) => request, + Err(detail) => return response_error(StatusCode::BAD_REQUEST, detail), + }; + let prefix = match audience { + HealthAudience::Public => "/api/public/health/v2/", + HealthAudience::Authenticated => "/api/users/me/health/v2/", + HealthAudience::Admin => "/api/admin/endpoints/health/v2/", + }; + let tail = path.strip_prefix(prefix).unwrap_or_default(); + if tail != "summary" + && tail != "objects" + && !tail + .strip_prefix("objects/") + .is_some_and(|id| !id.is_empty() && !id.contains('/')) + { + return response_error(StatusCode::NOT_FOUND, "Health resource not found"); + } + match build_health_payload(state, &request, tail, audience, now_ms).await { + Ok(Some(payload)) => Json(payload).into_response(), + Ok(None) => response_error( + StatusCode::NOT_FOUND, + "Health object is not published or does not exist", + ), + Err(error) => { + tracing::warn!(error = %crate::error::redact_error_detail(&format!("{error:?}")), "health observation query failed"); + response_error( + StatusCode::SERVICE_UNAVAILABLE, + "Health observations are temporarily unavailable", + ) + } + } +} + +async fn admin_objects( + state: &AppState, + kind: HealthObservationObjectKind, +) -> Result, GatewayError> { + let mut objects = BTreeMap::new(); + match kind { + HealthObservationObjectKind::ApiFormat => { + let providers = state.list_provider_catalog_providers(true).await?; + let ids: Vec<_> = providers + .iter() + .map(|provider| provider.id.clone()) + .collect(); + for endpoint in state + .list_provider_catalog_endpoints_by_provider_ids(&ids) + .await? + { + if endpoint.is_active { + objects.insert(endpoint.api_format.clone(), endpoint.api_format); + } + } + } + HealthObservationObjectKind::Provider => { + for provider in state.list_provider_catalog_providers(false).await? { + objects.insert(provider.id, provider.name); + } + } + HealthObservationObjectKind::Model => { + let mut offset = 0; + loop { + let page = state + .list_admin_global_models(&AdminGlobalModelListQuery { + offset, + limit: 500, + ..Default::default() + }) + .await?; + for model in &page.items { + objects.insert(model.name.clone(), model.display_name.clone()); + } + offset += page.items.len(); + if offset >= page.total || page.items.is_empty() { + break; + } + if offset >= 10_000 { + return Err(GatewayError::Internal( + "Health model catalog exceeds query budget".into(), + )); + } + } + } + } + Ok(objects) +} + +async fn build_health_payload( + state: &AppState, + request: &HealthRequest, + tail: &str, + audience: HealthAudience, + now_ms: u64, +) -> Result, GatewayError> { + let policy = HealthPolicy::default(); + let public = audience == HealthAudience::Public; + let redact_internal = audience != HealthAudience::Admin; + let publication = if public { + Some(read_publication(state).await?) + } else { + None + }; + if publication.as_ref().is_some_and(|config| !config.enabled) { + return Ok(None); + } + let published: BTreeMap<_, _> = publication + .as_ref() + .map(|config| { + config + .objects + .iter() + .filter(|object| object.kind == request.kind) + .map(|object| (object.value.clone(), object)) + .collect() + }) + .unwrap_or_default(); + let mut names = if public { + published + .iter() + .map(|(value, object)| (value.clone(), object.display_name.clone())) + .collect() + } else { + admin_objects(state, request.kind).await? + }; + let observation = if public && published.is_empty() { + Default::default() + } else { + state + .data + .summarize_health_observations(&HealthObservationQuery { + from_unix_ms: request.from_unix_ms, + to_unix_ms: request.to_unix_ms, + object_kind: request.kind, + object_values: public.then(|| published.keys().cloned().collect()), + segments: 24, + }) + .await + .map_err(|error| GatewayError::Internal(error.to_string()))? + }; + let mut metrics_by_value = BTreeMap::new(); + let mut timeline_by_value = BTreeMap::new(); + for object in observation.objects { + if !public || published.contains_key(&object.object_value) { + names + .entry(object.object_value.clone()) + .or_insert_with(|| object.object_value.clone()); + timeline_by_value.insert(object.object_value.clone(), object.timeline); + metrics_by_value.insert(object.object_value, object.metrics); + } + } + let mut objects = Vec::new(); + for (value, name) in names { + let metrics = metrics_by_value.remove(&value).unwrap_or_default(); + let id = if public { + published[&value].public_id.clone() + } else { + URL_SAFE_NO_PAD.encode(value.as_bytes()) + }; + let mut service = public_projection(id, request.kind, name, &metrics, &policy); + service.timeline = timeline_by_value + .remove(&value) + .unwrap_or_default() + .into_iter() + .map(|bucket| PublicHealthBucket { + from: unix_ms_to_rfc3339(bucket.from_unix_ms).unwrap_or_default(), + to: unix_ms_to_rfc3339(bucket.to_unix_ms).unwrap_or_default(), + status: policy.status( + bucket.metrics.service_succeeded_count, + bucket.metrics.service_failed_count, + bucket.metrics.unknown_failure_count, + ), + service_availability: HealthRatio::new( + bucket.metrics.service_succeeded_count, + bucket + .metrics + .service_succeeded_count + .saturating_add(bucket.metrics.service_failed_count), + ), + unknown_failure_count: bucket.metrics.unknown_failure_count, + }) + .collect(); + objects.push(AdminHealthObject { + service, + source_value: value, + attempts: AdminAttemptMetrics { + succeeded_count: metrics.attempt_succeeded_count, + failed_count: metrics.attempt_failed_count, + in_progress_count: metrics.attempt_in_progress_count, + cancelled_count: metrics.attempt_cancelled_count, + success: HealthRatio::new( + metrics.attempt_succeeded_count, + metrics + .attempt_succeeded_count + .saturating_add(metrics.attempt_failed_count), + ), + }, + }); + } + let status = overall_status(objects.iter().map(|object| object.service.status)); + let meta = json!({ + "schema_version": 2, "metric_version": policy.version, + "scope": { "kind": match audience { HealthAudience::Public => "published", HealthAudience::Authenticated => "authenticated", HealthAudience::Admin => "installation" }, "object_kind": request.kind }, + "range": { "from": unix_ms_to_rfc3339(request.from_unix_ms), "to": unix_ms_to_rfc3339(request.to_unix_ms), "timezone": "UTC", "time_basis": "request_started_at" }, + "generated_at": unix_ms_to_rfc3339(now_ms), + "data_through": observation.data_through_unix_ms.and_then(unix_ms_to_rfc3339), + "freshness": if observation.data_through_unix_ms.is_some_and(|time| now_ms.saturating_sub(time) > 120_000) { "stale" } else if observation.data_through_unix_ms.is_some() { "current" } else { "unknown" }, + "policy": policy, + }); + let data = if tail == "summary" { + json!({ "status": status, "object_count": objects.len(), + "healthy_count": objects.iter().filter(|object| object.service.status == HealthStatus::Healthy).count(), + "degraded_count": objects.iter().filter(|object| object.service.status == HealthStatus::Degraded).count(), + "unavailable_count": objects.iter().filter(|object| object.service.status == HealthStatus::Unavailable).count(), + "unknown_count": objects.iter().filter(|object| object.service.status == HealthStatus::Unknown).count(), + "requests": public_projection(String::new(), request.kind, String::new(), &observation.overall, &policy), + }) + } else if let Some(id) = tail.strip_prefix("objects/") { + let Some(object) = objects.iter().find(|object| object.service.id == id) else { + return Ok(None); + }; + if redact_internal { + serde_json::to_value(&object.service) + } else { + serde_json::to_value(object) + } + .map_err(|error| GatewayError::Internal(error.to_string()))? + } else { + let items: Vec = objects + .iter() + .skip(request.offset) + .take(request.limit) + .map(|object| { + if redact_internal { + serde_json::to_value(&object.service) + } else { + serde_json::to_value(object) + } + }) + .collect::>() + .map_err(|error| GatewayError::Internal(error.to_string()))?; + json!({ "items": items, "total": objects.len(), "limit": request.limit, "offset": request.offset }) + }; + Ok(Some(json!({ "meta": meta, "data": data }))) +} diff --git a/apps/aether-gateway/src/handlers/shared/health_monitor/mod.rs b/apps/aether-gateway/src/handlers/shared/health_monitor/mod.rs new file mode 100644 index 000000000..1131456cc --- /dev/null +++ b/apps/aether-gateway/src/handlers/shared/health_monitor/mod.rs @@ -0,0 +1,8 @@ +mod api; +mod policy; +mod publication; + +pub(crate) use api::{build_health_v2_response, build_publication_response, HealthAudience}; + +#[cfg(test)] +mod tests; diff --git a/apps/aether-gateway/src/handlers/shared/health_monitor/policy.rs b/apps/aether-gateway/src/handlers/shared/health_monitor/policy.rs new file mode 100644 index 000000000..f7ac3d7e0 --- /dev/null +++ b/apps/aether-gateway/src/handlers/shared/health_monitor/policy.rs @@ -0,0 +1,95 @@ +use serde::Serialize; + +pub(super) const POLICY_VERSION: &str = "service-health-v1"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum HealthStatus { + Healthy, + Degraded, + Unavailable, + Unknown, +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct HealthPolicy { + pub version: &'static str, + pub minimum_samples: u64, + pub healthy_threshold: f64, + pub degraded_threshold: f64, +} + +impl Default for HealthPolicy { + fn default() -> Self { + Self { + version: POLICY_VERSION, + minimum_samples: 20, + healthy_threshold: 0.99, + degraded_threshold: 0.95, + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub(super) struct HealthRatio { + pub numerator: u64, + pub denominator: u64, + pub value: Option, +} + +impl HealthRatio { + pub fn new(numerator: u64, denominator: u64) -> Self { + Self { + numerator, + denominator, + value: (denominator > 0).then(|| numerator as f64 / denominator as f64), + } + } +} + +impl HealthPolicy { + pub fn status(&self, successes: u64, failures: u64, unknown: u64) -> HealthStatus { + let samples = successes.saturating_add(failures); + if samples < self.minimum_samples { + return HealthStatus::Unknown; + } + if unknown > 0 { + let best_possible_rate = + successes.saturating_add(unknown) as f64 / samples.saturating_add(unknown) as f64; + return if best_possible_rate < self.degraded_threshold { + HealthStatus::Unavailable + } else { + HealthStatus::Unknown + }; + } + let ratio = successes as f64 / samples as f64; + if ratio >= self.healthy_threshold { + HealthStatus::Healthy + } else if ratio >= self.degraded_threshold { + HealthStatus::Degraded + } else { + HealthStatus::Unavailable + } + } +} + +pub(super) fn overall_status(statuses: impl Iterator) -> HealthStatus { + let mut result = HealthStatus::Healthy; + let mut count = 0; + for status in statuses { + count += 1; + match status { + HealthStatus::Unavailable => return HealthStatus::Unavailable, + HealthStatus::Degraded => result = HealthStatus::Degraded, + HealthStatus::Unknown if result == HealthStatus::Healthy => { + result = HealthStatus::Unknown + } + _ => {} + } + } + if count == 0 { + HealthStatus::Unknown + } else { + result + } +} diff --git a/apps/aether-gateway/src/handlers/shared/health_monitor/publication.rs b/apps/aether-gateway/src/handlers/shared/health_monitor/publication.rs new file mode 100644 index 000000000..2eb33ed8b --- /dev/null +++ b/apps/aether-gateway/src/handlers/shared/health_monitor/publication.rs @@ -0,0 +1,60 @@ +use aether_data_contracts::repository::usage::HealthObservationObjectKind; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +pub(super) const PUBLICATION_KEY: &str = "health_publication_v1"; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct HealthPublication { + pub enabled: bool, + pub objects: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct PublishedHealthObject { + pub public_id: String, + pub kind: HealthObservationObjectKind, + pub value: String, + pub display_name: String, +} + +impl HealthPublication { + pub fn validate(&self) -> Result<(), &'static str> { + if self.objects.len() > 200 { + return Err("At most 200 public health objects may be published"); + } + let mut ids = BTreeSet::new(); + let mut values = BTreeSet::new(); + for object in &self.objects { + if object.public_id.is_empty() + || object.public_id.len() > 80 + || !object + .public_id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err( + "Public IDs must contain 1 to 80 letters, digits, hyphens or underscores", + ); + } + if object.kind == HealthObservationObjectKind::Provider { + return Err("Internal providers cannot be published as public health objects"); + } + if object.value.trim().is_empty() + || object.value.len() > 256 + || object.display_name.trim().is_empty() + || object.display_name.len() > 120 + { + return Err("Every public object requires a bounded source value and display name"); + } + if !ids.insert(object.public_id.clone()) + || !values.insert(format!("{:?}:{}", object.kind, object.value)) + { + return Err("Public IDs and health source objects must be unique"); + } + } + Ok(()) + } +} diff --git a/apps/aether-gateway/src/handlers/shared/health_monitor/tests.rs b/apps/aether-gateway/src/handlers/shared/health_monitor/tests.rs new file mode 100644 index 000000000..353235e2b --- /dev/null +++ b/apps/aether-gateway/src/handlers/shared/health_monitor/tests.rs @@ -0,0 +1,103 @@ +use super::api::{public_projection, AdminAttemptMetrics, AdminHealthObject, HealthRequest}; +use super::policy::{overall_status, HealthPolicy, HealthRatio, HealthStatus}; +use super::publication::HealthPublication; +use aether_data_contracts::repository::usage::{ + HealthObservationMetrics, HealthObservationObjectKind, +}; +use serde_json::json; + +#[test] +fn health_policy_distinguishes_unknown_and_insufficient_samples() { + let policy = HealthPolicy::default(); + assert_eq!(policy.status(0, 0, 0), HealthStatus::Unknown); + assert_eq!(policy.status(19, 0, 0), HealthStatus::Unknown); + assert_eq!(policy.status(100, 0, 1), HealthStatus::Unknown); + assert_eq!(policy.status(0, 100, 1), HealthStatus::Unavailable); + assert_eq!(policy.status(99, 1, 0), HealthStatus::Healthy); + assert_eq!(policy.status(96, 4, 0), HealthStatus::Degraded); + assert_eq!(policy.status(90, 10, 0), HealthStatus::Unavailable); + assert_eq!(HealthRatio::new(0, 0).value, None); + assert_eq!( + overall_status([HealthStatus::Healthy, HealthStatus::Unknown].into_iter()), + HealthStatus::Unknown + ); +} + +#[test] +fn health_query_rejects_ambiguous_and_internal_public_filters() { + assert!(HealthRequest::parse(Some("provider_id=secret"), true, 30_000_000).is_err()); + assert!(HealthRequest::parse(Some("kind=provider"), true, 30_000_000).is_err()); + assert!(HealthRequest::parse(Some("window=6h&window=24h"), false, 30_000_000).is_err()); + assert!(HealthRequest::parse(Some("limit=101"), false, 30_000_000).is_err()); + let query = HealthRequest::parse( + Some("kind=model&window=1h&limit=50&offset=25"), + true, + 30_000_000, + ) + .unwrap(); + assert_eq!(query.from_unix_ms, 26_400_000); + assert_eq!(query.to_unix_ms, 30_000_000); + assert_eq!(query.offset, 25); +} + +#[test] +fn public_publication_is_explicit_and_rejects_provider_and_duplicate_sources() { + let config: HealthPublication = serde_json::from_value(json!({"enabled": true, "objects": [ + {"public_id": "chat", "kind": "api_format", "value": "openai:chat", "display_name": "Chat"} + ]})) + .unwrap(); + assert!(config.validate().is_ok()); + let mut duplicated = config.clone(); + duplicated.objects.push(duplicated.objects[0].clone()); + assert!(duplicated.validate().is_err()); + let mut provider = config; + provider.objects[0].kind = HealthObservationObjectKind::Provider; + assert!(provider.validate().is_err()); + assert!(serde_json::from_value::( + json!({"enabled": true, "objects": [], "publish_all": true}) + ) + .is_err()); +} + +#[test] +fn public_dto_cannot_serialize_internal_source_or_attempts() { + let metrics = HealthObservationMetrics { + request_count: 105, + succeeded_count: 100, + failed_count: 4, + cancelled_count: 1, + service_succeeded_count: 100, + service_failed_count: 2, + excluded_count: 2, + unknown_failure_count: 1, + ..Default::default() + }; + let public = public_projection( + "chat".into(), + HealthObservationObjectKind::ApiFormat, + "Chat".into(), + &metrics, + &HealthPolicy::default(), + ); + assert_eq!(public.request_success.denominator, 105); + assert_eq!(public.service_availability.denominator, 102); + let admin = AdminHealthObject { + service: public.clone(), + source_value: "internal-provider-id".into(), + attempts: AdminAttemptMetrics { + succeeded_count: 100, + failed_count: 20, + in_progress_count: 1, + cancelled_count: 1, + success: HealthRatio::new(100, 120), + }, + }; + let public_json = serde_json::to_value(public).unwrap(); + assert!(public_json.get("source_value").is_none()); + assert!(public_json.get("attempts").is_none()); + assert!(!public_json.to_string().contains("internal-provider-id")); + assert!(serde_json::to_value(admin) + .unwrap() + .get("attempts") + .is_some()); +} diff --git a/apps/aether-gateway/src/handlers/shared/mod.rs b/apps/aether-gateway/src/handlers/shared/mod.rs index 97638b093..e79d86a1d 100644 --- a/apps/aether-gateway/src/handlers/shared/mod.rs +++ b/apps/aether-gateway/src/handlers/shared/mod.rs @@ -4,6 +4,7 @@ mod auth_api_key_secret; mod catalog; mod email_templates; mod external_models; +pub(crate) mod health_monitor; mod identity_oauth_provider_secret; mod multipart; mod normalize; diff --git a/apps/aether-gateway/src/handlers/shared/request_utils.rs b/apps/aether-gateway/src/handlers/shared/request_utils.rs index 62bec53aa..603116ca3 100644 --- a/apps/aether-gateway/src/handlers/shared/request_utils.rs +++ b/apps/aether-gateway/src/handlers/shared/request_utils.rs @@ -232,6 +232,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body( decision.route_kind.as_deref(), ) { (Some("endpoints_manage"), http::Method::POST, Some("create_provider_key")) + | (Some("endpoints_health"), http::Method::PUT, Some("health_v2_publication")) | (Some("endpoints_manage"), http::Method::POST, Some("create_endpoint")) | (Some("endpoints_manage"), http::Method::POST, Some("batch_delete_keys")) | (Some("endpoints_manage"), http::Method::POST, Some("refresh_quota")) @@ -341,6 +342,7 @@ pub(crate) fn admin_proxy_local_requires_buffered_body( | (Some("billing_manage"), http::Method::PUT, Some("update_rule")) | (Some("billing_manage"), http::Method::POST, Some("create_collector")) | (Some("billing_manage"), http::Method::PUT, Some("update_collector")) + | (Some("billing_manage"), http::Method::POST, Some("create_provider_expense")) | (Some("billing_manage"), http::Method::POST, Some("create_plan")) | (Some("billing_manage"), http::Method::PUT, Some("update_plan")) | (Some("billing_manage"), http::Method::PATCH, Some("set_plan_status")) diff --git a/apps/aether-gateway/src/lib.rs b/apps/aether-gateway/src/lib.rs index 57bd703a2..308493329 100644 --- a/apps/aether-gateway/src/lib.rs +++ b/apps/aether-gateway/src/lib.rs @@ -43,6 +43,7 @@ mod data; mod dispatch; mod email_delivery; mod error; +mod execution_activity; mod execution_runtime; mod executor; mod fallback_metrics; @@ -68,6 +69,7 @@ mod provider_key_auth; mod provider_pool_demand; pub(crate) use aether_provider_transport as provider_transport; mod rate_limit; +mod request_activity; mod request_candidate_queue; mod request_candidate_runtime; mod request_diagnostics; diff --git a/apps/aether-gateway/src/maintenance/runtime/runners.rs b/apps/aether-gateway/src/maintenance/runtime/runners.rs index 410234c47..6ef916303 100644 --- a/apps/aether-gateway/src/maintenance/runtime/runners.rs +++ b/apps/aether-gateway/src/maintenance/runtime/runners.rs @@ -669,8 +669,24 @@ pub(super) async fn run_pending_cleanup_once(app: &AppState) -> Result<(), DataL pub(super) async fn run_stats_hourly_aggregation_once( data: &GatewayDataState, ) -> Result { - let Some(summary) = perform_stats_hourly_aggregation_once(data).await? else { - return Ok(false); + let legacy = perform_stats_hourly_aggregation_once(data).await; + let overview_progress = match super::stats_hourly::perform_overview_rebuild_once(data).await { + Ok(progress) => progress, + Err(error) => { + warn!(event_name = "overview_rebuild_failed", error = ?error, + "overview rebuild deferred; legacy statistics remain available"); + 0 + } + }; + if overview_progress > 0 { + info!( + event_name = "overview_rebuild_progress", + progress = overview_progress, + "overview dirty projections rebuilt" + ); + } + let Some(summary) = legacy? else { + return Ok(overview_progress > 0); }; info!( diff --git a/apps/aether-gateway/src/maintenance/runtime/stats_hourly.rs b/apps/aether-gateway/src/maintenance/runtime/stats_hourly.rs index 246e2ac29..4805842ee 100644 --- a/apps/aether-gateway/src/maintenance/runtime/stats_hourly.rs +++ b/apps/aether-gateway/src/maintenance/runtime/stats_hourly.rs @@ -22,3 +22,19 @@ pub(super) async fn perform_stats_hourly_aggregation_once( }) .await } + +pub(super) async fn perform_overview_rebuild_once( + data: &GatewayDataState, +) -> Result { + if !data.has_stats_hourly_aggregation_backend() + || !system_config_bool(data, "enable_stats_aggregation", true).await? + { + return Ok(0); + } + let now_utc = Utc::now(); + data.rebuild_overview_buckets(&StatsHourlyAggregationInput { + target_hour_utc: stats_hourly_aggregation_target_hour(now_utc), + aggregated_at: now_utc, + }) + .await +} diff --git a/apps/aether-gateway/src/maintenance/runtime/workers.rs b/apps/aether-gateway/src/maintenance/runtime/workers.rs index 7275a9350..b03ace7b4 100644 --- a/apps/aether-gateway/src/maintenance/runtime/workers.rs +++ b/apps/aether-gateway/src/maintenance/runtime/workers.rs @@ -772,57 +772,96 @@ pub(crate) fn spawn_stats_hourly_aggregation_worker( app, crate::task_runtime::TASK_KEY_STATS_HOURLY_AGG, |app| async move { - let data = app.data.clone(); - let mut deferred_since = None; - tokio::time::sleep(STATS_AGGREGATION_STARTUP_GRACE).await; - loop { - let mut processed = 0_usize; - let mut deferred = false; - while processed < STATS_HOURLY_CATCH_UP_BURST_LIMIT { - let permit = STATS_AGGREGATION_GATE - .acquire() - .await - .expect("stats aggregation gate should remain open"); - if should_defer_stats_aggregation( - &app, + // Both loops belong to the singleton lease future. Losing the + // lease/shutting down drops them together; no detached task survives. + let drain = async { + let data = app.data.clone(); + let mut deferred_since = None; + let mut interval = tokio::time::interval(Duration::from_secs(10)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + if should_defer_for_database_pressure( &data, - "stats_hourly_aggregation", + "overview_dirty_drain", &mut deferred_since, ) { - drop(permit); - deferred = true; - break; + continue; } - match run_stats_hourly_aggregation_once(&data).await { - Ok(true) => { - processed += 1; - tokio::time::sleep(STATS_CATCH_UP_BUCKET_PAUSE).await; - drop(permit); + // At most ten batches per tick; normal traffic and idle + // installations stop after the first empty batch. + for _ in 0..10 { + match data.drain_overview_dirty_events(Utc::now()).await { + Ok(0) => break, + Ok(_) => tokio::task::yield_now().await, + Err(err) => { + log_maintenance_worker_failure( + "overview_dirty_drain", + "tick", + &err, + ); + break; + } } - Ok(false) => break, - Err(err) => { - log_maintenance_worker_failure( - "stats_hourly_aggregation", - "tick", - &err, - ); + } + } + }; + let hourly = async { + let data = app.data.clone(); + let mut deferred_since = None; + tokio::time::sleep(STATS_AGGREGATION_STARTUP_GRACE).await; + loop { + let mut processed = 0_usize; + let mut deferred = false; + while processed < STATS_HOURLY_CATCH_UP_BURST_LIMIT { + let permit = STATS_AGGREGATION_GATE + .acquire() + .await + .expect("stats aggregation gate should remain open"); + if should_defer_stats_aggregation( + &app, + &data, + "stats_hourly_aggregation", + &mut deferred_since, + ) { + drop(permit); + deferred = true; break; } + match run_stats_hourly_aggregation_once(&data).await { + Ok(true) => { + processed += 1; + tokio::time::sleep(STATS_CATCH_UP_BUCKET_PAUSE).await; + drop(permit); + } + Ok(false) => break, + Err(err) => { + log_maintenance_worker_failure( + "stats_hourly_aggregation", + "tick", + &err, + ); + break; + } + } } - } - if deferred { - tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await; - continue; - } + if deferred { + tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await; + continue; + } - if processed >= STATS_HOURLY_CATCH_UP_BURST_LIMIT { - continue; - } + if processed >= STATS_HOURLY_CATCH_UP_BURST_LIMIT { + continue; + } - tokio::time::sleep(duration_until_next_stats_hourly_aggregation_run(Utc::now())) + tokio::time::sleep( + duration_until_next_stats_hourly_aggregation_run(Utc::now()), + ) .await; - } + } + }; + tokio::join!(drain, hourly); }, )) } diff --git a/apps/aether-gateway/src/orchestration/mod.rs b/apps/aether-gateway/src/orchestration/mod.rs index 4c2decec4..66cfd365b 100644 --- a/apps/aether-gateway/src/orchestration/mod.rs +++ b/apps/aether-gateway/src/orchestration/mod.rs @@ -155,6 +155,17 @@ pub(crate) fn with_error_flow_report_context( error_flow: Value, ) -> Option { let mut object = report_context?.as_object()?.clone(); + if !object.contains_key("analytics_failure") + && error_flow.get("source").and_then(Value::as_str) == Some("upstream_response") + && error_flow + .get("status_code") + .and_then(Value::as_u64) + .is_some_and(|status| status >= 400) + { + object.insert("analytics_failure".into(), json!({ + "origin": "upstream", "stage": "response", "reason": "upstream_response_error", "schema_version": 1, + })); + } object.insert("error_flow".to_string(), error_flow); Some(Value::Object(object)) } diff --git a/apps/aether-gateway/src/provider_pool_demand.rs b/apps/aether-gateway/src/provider_pool_demand.rs index 6506cb56e..2fbb310e4 100644 --- a/apps/aether-gateway/src/provider_pool_demand.rs +++ b/apps/aether-gateway/src/provider_pool_demand.rs @@ -49,6 +49,7 @@ pub(crate) struct ProviderPoolDemandSnapshot { pub(crate) struct ProviderPoolInFlightGuard { kind: ProviderPoolInFlightGuardKind, provider_key_permit: Option, + observed_activity: Option, released: bool, } @@ -73,11 +74,28 @@ enum ProviderPoolInFlightGuardKind { } impl ProviderPoolInFlightGuard { + fn observe_execution( + guard: Option, + activity: crate::execution_activity::ExecutionActivityGuard, + ) -> Self { + // Observability remains active when provider demand tracking is disabled. + let mut guard = guard.unwrap_or(Self { + kind: ProviderPoolInFlightGuardKind::Disabled, + provider_key_permit: None, + observed_activity: None, + released: false, + }); + guard.observed_activity = Some(activity); + guard + } + pub(crate) async fn release(mut self) { self.release_inner().await; } async fn release_inner(&mut self) { + // Observation ends with execution, before distributed permit cleanup can wait. + self.observed_activity.take(); if self.released { return; } @@ -358,6 +376,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard { kind: ProviderPoolInFlightGuardKind::Disabled, provider_key_permit: Some(provider_key_permit), + observed_activity: None, released: false, }), ); @@ -369,6 +388,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard { kind: ProviderPoolInFlightGuardKind::Disabled, provider_key_permit: Some(provider_key_permit), + observed_activity: None, released: false, }), ); @@ -381,6 +401,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( counter, }, provider_key_permit, + observed_activity: None, released: false, })); } @@ -392,6 +413,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( counter, }, provider_key_permit, + observed_activity: None, released: false, })); } @@ -417,6 +439,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard { kind: ProviderPoolInFlightGuardKind::Disabled, provider_key_permit: Some(provider_key_permit), + observed_activity: None, released: false, }), ); @@ -431,6 +454,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard { kind: ProviderPoolInFlightGuardKind::Disabled, provider_key_permit: Some(provider_key_permit), + observed_activity: None, released: false, }), ); @@ -454,6 +478,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( renew_handle: Some(renew_handle), }, provider_key_permit, + observed_activity: None, released: false, })) } @@ -461,6 +486,47 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit( pub(crate) async fn acquire_provider_pool_execution_guard( state: &AppState, plan: &ExecutionPlan, + report_context: Option<&serde_json::Value>, +) -> Result { + let admission = acquire_provider_pool_execution_guard_unobserved(state, plan).await?; + let ProviderPoolInFlightAdmission::Acquired(guard) = admission else { + return Ok(admission); + }; + let requested_model = report_context + .and_then(|context| context.get("model")) + .and_then(serde_json::Value::as_str); + let observation_id = execution_observation_request_id(&plan.request_id, report_context); + let activity = state.execution_activity.begin( + observation_id.as_ref(), + &plan.provider_id, + plan.provider_name.as_deref(), + requested_model, + ); + Ok(ProviderPoolInFlightAdmission::Acquired(Some( + ProviderPoolInFlightGuard::observe_execution(guard, activity), + ))) +} + +fn execution_observation_request_id<'a>( + request_id: &'a str, + report_context: Option<&serde_json::Value>, +) -> std::borrow::Cow<'a, str> { + // Transparent Responses retries have distinct audit request IDs, but share + // one server-issued logical turn ID. Count that client request once. + report_context + .and_then(|context| context.get("websocket_logical_turn_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| std::borrow::Cow::Owned(format!("ws:{value}"))) + .unwrap_or(std::borrow::Cow::Borrowed(request_id)) +} + +/// Long-lived audio/live sockets use the same capacity permits, but are not +/// individual requests and must not contribute to per-request RPM/concurrency. +pub(crate) async fn acquire_provider_pool_execution_guard_unobserved( + state: &AppState, + plan: &ExecutionPlan, ) -> Result { let concurrent_limit = state .read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id)) @@ -751,6 +817,109 @@ mod tests { drop(replacement); } + #[test] + fn execution_observation_deduplicates_websocket_attempts_by_logical_turn() { + let context = serde_json::json!({"websocket_logical_turn_id": "logical-turn-1"}); + assert_eq!( + execution_observation_request_id("attempt-1", Some(&context)), + execution_observation_request_id("attempt-2", Some(&context)), + ); + assert_ne!( + execution_observation_request_id("logical-turn-1", None), + execution_observation_request_id("attempt-1", Some(&context)), + ); + assert_eq!( + execution_observation_request_id("http-request", None), + "http-request" + ); + assert_eq!( + execution_observation_request_id( + "http-request", + Some(&serde_json::json!({"websocket_logical_turn_id": " "})) + ), + "http-request", + ); + } + + #[tokio::test] + async fn execution_activity_survives_disabled_pool_tracking_and_clears_on_release_or_drop() { + let activity = Arc::new(crate::execution_activity::ExecutionActivity::default()); + // Pool mode Off (without a key limit) returns None. Execution admission + // still attaches the independent observation to a disabled wrapper. + let mut guard = ProviderPoolInFlightGuard::observe_execution( + None, + activity.begin( + "request-1", + "provider-1", + Some("Provider"), + Some("client-model"), + ), + ); + let snapshot = activity.snapshot(); + assert_eq!(snapshot["providers"][0]["current_concurrency"], 1); + assert_eq!(snapshot["providers"][0]["requests_per_minute"], 1); + assert_eq!(snapshot["models"][0]["model"], "client-model"); + guard.release_inner().await; + assert_eq!( + activity.snapshot()["providers"][0]["current_concurrency"], + 0 + ); + assert!(guard.observed_activity.is_none()); + drop(guard); + + let guard = ProviderPoolInFlightGuard::observe_execution( + None, + activity.begin( + "request-2", + "provider-1", + Some("Provider"), + Some("client-model"), + ), + ); + assert_eq!(activity.snapshot()["models"][0]["current_concurrency"], 1); + drop(guard); + let snapshot = activity.snapshot(); + assert_eq!(snapshot["models"][0]["current_concurrency"], 0); + assert_eq!(snapshot["providers"][0]["requests_per_minute"], 2); + } + + #[tokio::test] + async fn execution_activity_releases_with_an_existing_pool_guard() { + let runtime = Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default())); + let activity = Arc::new(crate::execution_activity::ExecutionActivity::default()); + let provider_id = "provider-observed-release"; + let pool_guard = acquire_provider_pool_in_flight_guard( + runtime.clone(), + provider_id, + "request-1", + Some("candidate-1"), + "key-1", + ) + .await + .expect("pool guard should be acquired"); + let guard = ProviderPoolInFlightGuard::observe_execution( + Some(pool_guard), + activity.begin("request-1", provider_id, Some("Provider"), None), + ); + assert_eq!( + provider_pool_live_in_flight_count(runtime.as_ref(), provider_id).await, + 1 + ); + assert_eq!( + activity.snapshot()["providers"][0]["current_concurrency"], + 1 + ); + guard.release().await; + assert_eq!( + provider_pool_live_in_flight_count(runtime.as_ref(), provider_id).await, + 0 + ); + assert_eq!( + activity.snapshot()["providers"][0]["current_concurrency"], + 0 + ); + } + #[tokio::test] async fn demand_snapshot_uses_instant_in_flight_for_fast_rise_and_ema_for_fall() { let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default()); diff --git a/apps/aether-gateway/src/request_activity.rs b/apps/aether-gateway/src/request_activity.rs new file mode 100644 index 000000000..80feb21b7 --- /dev/null +++ b/apps/aether-gateway/src/request_activity.rs @@ -0,0 +1,302 @@ +//! Node-local request concurrency, integrated at lifecycle edges rather than sampled. +//! +//! Minute buckets bound memory independently of traffic volume. A request spanning +//! a bucket/day boundary contributes to both sides, including while no API polls us. +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery; +use aether_runtime::{AdmissionPermit, AdmissionPermitHealth}; +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; + +const MINUTE_US: i64 = 60_000_000; +const RETAIN_MINUTES: i64 = 48 * 60; + +#[derive(Debug, Default)] +struct Minute { + start_us: i64, + request_microseconds: u128, + peak: u64, +} + +#[derive(Debug)] +struct History { + observed_from_us: i64, + through_us: i64, + active: u64, + minutes: VecDeque, +} + +impl History { + fn new(now_us: i64) -> Self { + Self { + observed_from_us: now_us, + through_us: now_us, + active: 0, + minutes: VecDeque::new(), + } + } + + fn minute(&mut self, at_us: i64) -> &mut Minute { + let start_us = at_us.div_euclid(MINUTE_US) * MINUTE_US; + if self + .minutes + .back() + .is_none_or(|bucket| bucket.start_us != start_us) + { + self.minutes.push_back(Minute { + start_us, + ..Minute::default() + }); + } + self.minutes.back_mut().expect("minute was inserted") + } + + fn advance(&mut self, now_us: i64) { + let now_us = now_us.max(self.through_us); + let retained_from = (now_us.div_euclid(MINUTE_US) - RETAIN_MINUTES) * MINUTE_US; + let mut cursor = self.through_us.max(retained_from); + while cursor < now_us { + let end = ((cursor.div_euclid(MINUTE_US) + 1) * MINUTE_US).min(now_us); + let active = self.active; + let bucket = self.minute(cursor); + bucket.request_microseconds += u128::from(active) * (end - cursor) as u128; + bucket.peak = bucket.peak.max(active); + cursor = end; + } + self.through_us = now_us; + while self + .minutes + .front() + .is_some_and(|bucket| bucket.start_us < retained_from) + { + self.minutes.pop_front(); + } + } + + fn change(&mut self, now_us: i64, entering: bool) { + self.advance(now_us); + self.active = if entering { + self.active.saturating_add(1) + } else { + self.active.saturating_sub(1) + }; + let active = self.active; + let at_us = self.through_us; + let bucket = self.minute(at_us); + bucket.peak = bucket.peak.max(active); + } + + fn today(&mut self, timezone: &str, now_us: i64) -> Result { + self.advance(now_us); + let through = DateTime::from_timestamp_micros(self.through_us) + .ok_or_else(|| "invalid concurrency observation timestamp".to_string())?; + let day_start = UsageDashboardAnalyticsQuery { + timezone: timezone.into(), + } + .today_start(through) + .map_err(|error| error.to_string())?; + let day_start_us = day_start.timestamp_micros(); + // Current IANA offsets/day boundaries are minute aligned. Refuse to + // misrepresent an unsupported sub-minute historical boundary as exact. + if day_start_us.rem_euclid(MINUTE_US) != 0 { + return Err("concurrency day boundary is not minute aligned".into()); + } + let observed_from_us = self.observed_from_us.max(day_start_us); + let duration_us = self.through_us.saturating_sub(observed_from_us); + let (area, peak) = self + .minutes + .iter() + .filter(|minute| minute.start_us >= day_start_us && minute.start_us <= self.through_us) + .fold((0u128, self.active), |(area, peak), minute| { + (area + minute.request_microseconds, peak.max(minute.peak)) + }); + Ok(json!({ + "avg": (duration_us > 0).then(|| area as f64 / duration_us as f64), + "peak": peak, + "observed_from": DateTime::from_timestamp_micros(observed_from_us), + "observed_through": through, + "scope": "node", + "measurement": "http_and_responses_websocket_requests", + "coverage": if self.observed_from_us <= day_start_us { "complete" } else { "partial" }, + })) + } +} + +#[derive(Debug)] +pub(crate) struct RequestActivity { + started_at: Instant, + started_at_us: i64, + history: Mutex, +} + +impl Default for RequestActivity { + fn default() -> Self { + let started_at = Instant::now(); + let started_at_us = Utc::now().timestamp_micros(); + Self { + started_at, + started_at_us, + history: Mutex::new(History::new(started_at_us)), + } + } +} + +impl RequestActivity { + fn now_us(&self) -> i64 { + self.started_at_us + .saturating_add(self.started_at.elapsed().as_micros().min(i64::MAX as u128) as i64) + } + + pub(crate) fn begin(self: &Arc) -> RequestActivityGuard { + self.history + .lock() + .unwrap_or_else(|error| error.into_inner()) + .change(self.now_us(), true); + RequestActivityGuard { + activity: Arc::clone(self), + } + } + + pub(crate) fn today(&self, timezone: &str) -> Result { + self.history + .lock() + .unwrap_or_else(|error| error.into_inner()) + .today(timezone, self.now_us()) + } + + #[cfg(test)] + pub(crate) fn active(&self) -> u64 { + self.history.lock().unwrap().active + } +} + +#[derive(Debug)] +pub(crate) struct RequestActivityGuard { + activity: Arc, +} + +impl RequestActivityGuard { + pub(crate) fn into_admission_permit(self) -> AdmissionPermit { + // This guard observes lifecycle only; it neither limits nor cancels work. + AdmissionPermit::from_parts(None, Some(self)).expect("activity guard is present") + } +} + +impl AdmissionPermitHealth for RequestActivityGuard { + fn is_healthy(&self) -> bool { + true + } + fn requires_health_poll(&self) -> bool { + false + } +} + +impl Drop for RequestActivityGuard { + fn drop(&mut self) { + self.activity + .history + .lock() + .unwrap_or_else(|error| error.into_inner()) + .change(self.activity.now_us(), false); + } +} + +impl crate::AppState { + pub(crate) fn today_concurrency(&self, timezone: &str) -> Result { + self.request_activity.today(timezone) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(value: &str) -> i64 { + DateTime::parse_from_rfc3339(value) + .unwrap() + .timestamp_micros() + } + + #[test] + fn concurrency_integrates_time_instead_of_averaging_event_samples() { + let start = at("2026-09-19T00:00:00Z"); + let mut history = History::new(start); + history.change(start, true); + history.change(start + 10_000_000, true); + history.change(start + 20_000_000, false); + history.change(start + 30_000_000, false); + let value = history.today("UTC", start + 100_000_000).unwrap(); + assert_eq!(value["avg"], 0.4); + assert_eq!(value["peak"], 2); + assert_eq!(value["coverage"], "complete"); + } + + #[test] + fn concurrency_long_request_crosses_minutes_and_local_midnight() { + let start = at("2026-09-18T15:59:30Z"); + let mut history = History::new(start); + history.change(start, true); + let value = history.today("Asia/Shanghai", start + 150_000_000).unwrap(); + assert_eq!(value["avg"], 1.0); + assert_eq!(value["peak"], 1); + assert_eq!(value["observed_from"], "2026-09-18T16:00:00Z"); + assert_eq!(value["coverage"], "complete"); + history.change(start + 150_000_000, false); + let value = history.today("Asia/Shanghai", start + 270_000_000).unwrap(); + assert_eq!(value["avg"], 0.5); + } + + #[test] + fn concurrency_restart_only_claims_the_observed_part_of_the_day() { + let start = at("2026-09-19T12:00:00Z"); + let mut history = History::new(start); + let value = history.today("UTC", start + 60_000_000).unwrap(); + assert_eq!(value["avg"], 0.0); + assert_eq!(value["peak"], 0); + assert_eq!(value["coverage"], "partial"); + assert_eq!(value["observed_from"], "2026-09-19T12:00:00Z"); + assert!(History::new(start).today("UTC", start).unwrap()["avg"].is_null()); + assert!(history.today("not/a/timezone", start).is_err()); + } + + #[test] + fn concurrency_does_not_carry_yesterdays_peak_into_today() { + let start = at("2026-09-18T23:59:30Z"); + let mut history = History::new(start); + history.change(start, true); + history.change(start, true); + history.change(start + 20_000_000, false); + history.change(start + 30_000_000, false); + let value = history.today("UTC", start + 90_000_000).unwrap(); + assert_eq!(value["avg"], 0.0); + assert_eq!(value["peak"], 0); + } + + #[test] + fn concurrency_handles_dst_and_bounds_memory_after_a_long_idle_gap() { + let start = at("2026-10-30T00:00:00Z"); + let mut history = History::new(start); + history.change(start, true); + let end = at("2026-11-02T04:30:00Z"); + let value = history.today("America/New_York", end).unwrap(); + assert_eq!(value["avg"], 1.0); + assert_eq!(value["observed_from"], "2026-11-01T04:00:00Z"); + assert_eq!(value["coverage"], "complete"); + assert!(history.minutes.len() <= RETAIN_MINUTES as usize + 1); + } + + #[test] + fn concurrency_permit_clones_share_one_lifecycle() { + let activity = Arc::new(RequestActivity::default()); + let permit = activity.begin().into_admission_permit(); + let background = permit.clone(); + assert_eq!(activity.active(), 1); + drop(permit); + assert_eq!(activity.active(), 1); + drop(background); + assert_eq!(activity.active(), 0); + } +} diff --git a/apps/aether-gateway/src/request_lifecycle.rs b/apps/aether-gateway/src/request_lifecycle.rs index bd52557c5..197b31b54 100644 --- a/apps/aether-gateway/src/request_lifecycle.rs +++ b/apps/aether-gateway/src/request_lifecycle.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use aether_routing_core::RoutingExecutionPolicy; @@ -16,6 +16,15 @@ use crate::GatewayError; tokio::task_local! { static CANCEL_ON_CLIENT_DISCONNECT: Arc; + static REQUEST_ACTIVITY: Arc>>; +} + +/// Begin observing only after routing chose local AI execution. The surrounding +/// request/body lifecycle keeps this holder alive through disconnect draining. +pub(crate) fn track_request_activity(permit: aether_runtime::AdmissionPermit) { + let _ = REQUEST_ACTIVITY.try_with(|activity| { + *activity.lock().unwrap_or_else(|error| error.into_inner()) = Some(permit); + }); } pub(crate) fn configure_client_disconnect(policy: RoutingExecutionPolicy) { @@ -59,23 +68,35 @@ where let diagnostics = Arc::new(RequestDiagnostics::default()); let cancel_for_response = Arc::clone(&cancel); let producer_for_request = producer.clone(); - let future = CANCEL_ON_CLIENT_DISCONNECT.scope( - Arc::clone(&cancel), - scope_request_diagnostics_with(Some(Arc::clone(&diagnostics)), async move { - let response = future.await?; - let complete_on_disconnect = !cancel_for_response.load(Ordering::Acquire); - if !complete_on_disconnect && producer.is_none() { - return Ok(response); - } - Ok(response.map(|body| { - Body::new(CompleteOnDisconnectBody { - body: Some(body), - diagnostics, - complete_on_disconnect, - producer, - }) - })) - }), + let activity = Arc::new(Mutex::new(None)); + let activity_for_response = Arc::clone(&activity); + let future = REQUEST_ACTIVITY.scope( + activity, + CANCEL_ON_CLIENT_DISCONNECT.scope( + Arc::clone(&cancel), + scope_request_diagnostics_with(Some(Arc::clone(&diagnostics)), async move { + let response = future.await?; + let complete_on_disconnect = !cancel_for_response.load(Ordering::Acquire); + if !complete_on_disconnect + && producer.is_none() + && activity_for_response + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_none() + { + return Ok(response); + } + Ok(response.map(|body| { + Body::new(CompleteOnDisconnectBody { + body: Some(body), + diagnostics, + complete_on_disconnect, + producer, + activity: Some(activity_for_response), + }) + })) + }), + ), ); CompleteOnDisconnectRequest { future: Some(Box::pin(future)), @@ -142,6 +163,7 @@ struct CompleteOnDisconnectBody { complete_on_disconnect: bool, // Drop the body first so its terminal handoff registers before this guard ends. producer: Option>, + activity: Option>>>, } impl HttpBody for CompleteOnDisconnectBody { @@ -159,6 +181,7 @@ impl HttpBody for CompleteOnDisconnectBody { if matches!(result, Poll::Ready(None | Some(Err(_)))) { self.body.take(); self.producer.take(); + self.activity.take(); } result } @@ -185,10 +208,12 @@ impl Drop for CompleteOnDisconnectBody { }; if let Ok(runtime) = tokio::runtime::Handle::try_current() { let producer = self.producer.take(); + let activity = self.activity.take(); runtime.spawn(scope_request_diagnostics_with( Some(Arc::clone(&self.diagnostics)), async move { let _producer = producer; + let _activity = activity; drain_body(body).await; }, )); @@ -341,10 +366,13 @@ mod tests { #[tokio::test] async fn usage_shutdown_waits_for_a_disconnected_request_before_headers() { let usage = Arc::new(UsageRuntime::disabled()); + let activity = Arc::new(crate::request_activity::RequestActivity::default()); + let activity_permit = activity.begin().into_admission_permit(); let (started_tx, started_rx) = oneshot::channel(); let (release_tx, release_rx) = oneshot::channel::<()>(); let request = tokio::spawn(run_request_with_usage(usage.clone(), async move { configure_client_disconnect(RoutingExecutionPolicy::default()); + track_request_activity(activity_permit); started_tx.send(()).unwrap(); release_rx.await.unwrap(); Ok(Response::new(Body::empty())) @@ -354,49 +382,79 @@ mod tests { assert!(request.await.unwrap_err().is_cancelled()); assert!(usage.shutdown(Duration::from_millis(30)).await.is_err()); assert_eq!(usage.metrics_snapshot().producers_in_flight, 1); + assert_eq!(activity.active(), 1); release_tx.send(()).unwrap(); usage.shutdown(Duration::from_secs(1)).await.unwrap(); assert_eq!(usage.metrics_snapshot().producers_in_flight, 0); + assert_eq!(activity.active(), 0); + } + + #[tokio::test] + async fn request_activity_releases_when_the_handler_fails_before_headers() { + let activity = Arc::new(crate::request_activity::RequestActivity::default()); + let permit = activity.begin().into_admission_permit(); + let result = run_request(async move { + track_request_activity(permit); + Err(GatewayError::Internal("test failure".into())) + }) + .await; + assert!(result.is_err()); + assert_eq!(activity.active(), 0); } #[tokio::test] async fn usage_shutdown_waits_for_disconnected_body_drain() { let usage = Arc::new(UsageRuntime::disabled()); + let activity = Arc::new(crate::request_activity::RequestActivity::default()); + let activity_permit = activity.begin().into_admission_permit(); let (sender, receiver) = mpsc::channel::>(1); let response = run_request_with_usage(usage.clone(), async move { configure_client_disconnect(RoutingExecutionPolicy::default()); - Ok(Response::new(Body::from_stream(stream::unfold( + track_request_activity(activity_permit); + let response = Response::new(Body::from_stream(stream::unfold( receiver, |mut receiver| async { receiver.recv().await.map(|item| (item, receiver)) }, - )))) + ))); + Ok(response) }) .await .unwrap(); drop(response); + assert_eq!( + activity.active(), + 1, + "background drain still owns the request" + ); assert!(usage.shutdown(Duration::from_millis(30)).await.is_err()); sender.send(Ok(Bytes::from_static(b"last"))).await.unwrap(); drop(sender); usage.shutdown(Duration::from_secs(1)).await.unwrap(); assert_eq!(usage.metrics_snapshot().producers_in_flight, 0); + assert_eq!(activity.active(), 0); } #[tokio::test] async fn tracked_bodies_release_shutdown_on_cancellation_or_eof() { for cancel_on_client_disconnect in [false, true] { let usage = Arc::new(UsageRuntime::disabled()); + let activity = Arc::new(crate::request_activity::RequestActivity::default()); + let activity_permit = activity.begin().into_admission_permit(); let (sender, receiver) = mpsc::channel::>(1); let response = run_request_with_usage(usage.clone(), async move { configure_client_disconnect(RoutingExecutionPolicy { cancel_on_client_disconnect, ..Default::default() }); - Ok(Response::new(Body::from_stream(stream::unfold( + track_request_activity(activity_permit); + let response = Response::new(Body::from_stream(stream::unfold( receiver, |mut receiver| async { receiver.recv().await.map(|item| (item, receiver)) }, - )))) + ))); + Ok(response) }) .await .unwrap(); + assert_eq!(activity.active(), 1); let mut body = response.into_body(); if cancel_on_client_disconnect { drop(body); @@ -407,13 +465,17 @@ mod tests { assert_eq!(usage.metrics_snapshot().producers_in_flight, 0); } usage.shutdown(Duration::from_secs(1)).await.unwrap(); + assert_eq!(activity.active(), 0); } } #[tokio::test] async fn connected_response_preserves_headers_size_hint_and_trailers() { - let response = run_request(async { + let activity = Arc::new(crate::request_activity::RequestActivity::default()); + let activity_permit = activity.begin().into_admission_permit(); + let response = run_request(async move { configure_client_disconnect(RoutingExecutionPolicy::default()); + track_request_activity(activity_permit); Ok(Response::builder() .status(201) .header("x-test", "unchanged") @@ -429,11 +491,14 @@ mod tests { response.into_body().collect().await.unwrap().to_bytes(), "hello" ); + assert_eq!(activity.active(), 0); let mut trailers = HeaderMap::new(); trailers.insert("x-finished", "yes".parse().unwrap()); + let activity_permit = activity.begin().into_admission_permit(); let response = run_request(async move { configure_client_disconnect(RoutingExecutionPolicy::default()); + track_request_activity(activity_permit); let frames = stream::iter([ Ok::<_, io::Error>(Frame::data(Bytes::from_static(b"hello"))), Ok(Frame::trailers(trailers)), diff --git a/apps/aether-gateway/src/state/app.rs b/apps/aether-gateway/src/state/app.rs index 1851b7c0c..6813bbf81 100644 --- a/apps/aether-gateway/src/state/app.rs +++ b/apps/aether-gateway/src/state/app.rs @@ -18,7 +18,7 @@ use super::super::async_task::{VideoTaskPollerConfig, VideoTaskService}; use super::super::cache::{ AuthApiKeyFeatureCacheKey, AuthApiKeyIdentityCacheKey, AuthApiKeyLastUsedCache, AuthContextCache, AuthSnapshotCache, DashboardResponseCache, DirectPlanBypassCache, - JsonValueCache, SchedulerAffinityCache, SystemConfigCache, ValueCache, + JsonValueCache, OverviewTotalCache, SchedulerAffinityCache, SystemConfigCache, ValueCache, }; use super::super::data::GatewayDataState; use super::super::fallback_metrics; @@ -388,6 +388,8 @@ pub struct AppState { pub(crate) runtime_state: Arc, pub(crate) internal_gateway_auth: Arc, pub(crate) usage_runtime: Arc, + pub(crate) request_activity: Arc, + pub(crate) execution_activity: Arc, pub(crate) video_tasks: Arc, pub(crate) video_task_poller: Option, pub(crate) frontdoor_runtime_guards: Arc, @@ -429,6 +431,7 @@ pub struct AppState { pub(crate) scheduler_affinity_cache: Arc, pub(crate) scheduler_affinity_epoch: Arc, pub(crate) dashboard_response_cache: Arc, + pub(crate) overview_total_cache: Arc, pub(crate) system_config_cache: Arc, pub(crate) endpoint_response_header_rules_cache: Arc>, pub(crate) candidate_row_page_cache: Arc, diff --git a/apps/aether-gateway/src/state/core.rs b/apps/aether-gateway/src/state/core.rs index ab462ed90..3438d515c 100644 --- a/apps/aether-gateway/src/state/core.rs +++ b/apps/aether-gateway/src/state/core.rs @@ -38,8 +38,9 @@ use super::super::async_task::{ }; use super::super::cache::{ AuthApiKeyLastUsedCache, AuthContextCache, AuthSnapshotCache, DashboardResponseCache, - DirectPlanBypassCache, JsonValueCache, SchedulerAffinityCache, SchedulerAffinitySnapshotEntry, - SchedulerAffinityTarget, SystemConfigCache, SystemConfigInflightRegistration, ValueCache, + DirectPlanBypassCache, JsonValueCache, OverviewTotalCache, SchedulerAffinityCache, + SchedulerAffinitySnapshotEntry, SchedulerAffinityTarget, SystemConfigCache, + SystemConfigInflightRegistration, ValueCache, }; use super::super::data::{GatewayDataConfig, GatewayDataState}; use super::super::fallback_metrics; @@ -254,6 +255,7 @@ impl AppState { } fn replace_foreground_data_state(&mut self, data: Arc) { + self.overview_total_cache = Arc::new(OverviewTotalCache::default()); self.clear_provider_transport_snapshot_cache(); self.invalidate_scheduler_affinity_cache(); self.invalidate_auth_context_cache(); @@ -351,6 +353,8 @@ impl AppState { runtime_state: runtime_state.clone(), internal_gateway_auth, usage_runtime: Arc::new(usage::UsageRuntime::disabled()), + request_activity: Arc::new(crate::request_activity::RequestActivity::default()), + execution_activity: Arc::new(crate::execution_activity::ExecutionActivity::default()), video_tasks: Arc::new(VideoTaskService::new( VideoTaskTruthSourceMode::PythonSyncReport, )), @@ -402,6 +406,7 @@ impl AppState { scheduler_affinity_cache: Arc::new(SchedulerAffinityCache::default()), scheduler_affinity_epoch: Arc::new(AtomicU64::new(0)), dashboard_response_cache: Arc::new(DashboardResponseCache::default()), + overview_total_cache: Arc::new(OverviewTotalCache::default()), system_config_cache: Arc::new(SystemConfigCache::default()), endpoint_response_header_rules_cache: Arc::new(JsonValueCache::default()), candidate_row_page_cache: Arc::new(crate::cache::CandidateRowPageCache::default()), diff --git a/apps/aether-gateway/src/state/runtime/announcements.rs b/apps/aether-gateway/src/state/runtime/announcements.rs index 259ec82b9..20d447a1e 100644 --- a/apps/aether-gateway/src/state/runtime/announcements.rs +++ b/apps/aether-gateway/src/state/runtime/announcements.rs @@ -1,6 +1,18 @@ use crate::{AppState, GatewayError}; impl AppState { + pub(crate) async fn list_user_announcements( + &self, + user_id: &str, + query: &aether_data::repository::announcements::UserAnnouncementListQuery, + ) -> Result + { + self.data + .list_user_announcements(user_id, query) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + pub(crate) async fn list_announcements( &self, query: &aether_data::repository::announcements::AnnouncementListQuery, diff --git a/apps/aether-gateway/src/state/runtime/billing/admin.rs b/apps/aether-gateway/src/state/runtime/billing/admin.rs index 5e05c1e79..caca50d0e 100644 --- a/apps/aether-gateway/src/state/runtime/billing/admin.rs +++ b/apps/aether-gateway/src/state/runtime/billing/admin.rs @@ -610,6 +610,17 @@ impl AppState { .map_err(data_error) } + pub(crate) async fn list_user_plan_entitlements_with_history( + &self, + user_id: &str, + include_inactive: bool, + ) -> Result>, GatewayError> { + self.data + .list_user_plan_entitlements_with_history(user_id, include_inactive) + .await + .map_err(data_error) + } + pub(crate) async fn revoke_user_plan_entitlement( &self, user_id: &str, diff --git a/apps/aether-gateway/src/state/runtime/billing/finance_queries.rs b/apps/aether-gateway/src/state/runtime/billing/finance_queries.rs index 5c0d5954d..999c236a7 100644 --- a/apps/aether-gateway/src/state/runtime/billing/finance_queries.rs +++ b/apps/aether-gateway/src/state/runtime/billing/finance_queries.rs @@ -14,6 +14,7 @@ use crate::{ impl AppState { pub(crate) async fn list_admin_wallets( &self, + user_id: Option<&str>, status: Option<&str>, owner_type: Option<&str>, limit: usize, @@ -22,6 +23,7 @@ impl AppState { let page = self .data .list_admin_wallets(&AdminWalletListQuery { + user_id: user_id.map(ToOwned::to_owned), status: status.map(ToOwned::to_owned), owner_type: owner_type.map(ToOwned::to_owned), limit, diff --git a/apps/aether-gateway/src/state/runtime/usage_queries.rs b/apps/aether-gateway/src/state/runtime/usage_queries.rs index 928f2f4f0..40ce8b980 100644 --- a/apps/aether-gateway/src/state/runtime/usage_queries.rs +++ b/apps/aether-gateway/src/state/runtime/usage_queries.rs @@ -3,6 +3,35 @@ use aether_data_contracts::repository::{candidates, usage}; use usage::{StoredUsageDailySummary, UsageDailyHeatmapQuery}; impl AppState { + pub(crate) async fn query_dashboard_analytics( + &self, + query: &usage::UsageDashboardAnalyticsQuery, + ) -> Result { + self.data + .query_dashboard_analytics(query) + .await + .map_err(|err| GatewayError::Internal(err.to_string())) + } + + pub(crate) async fn query_usage_analytics( + &self, + query: &usage::UsageAnalyticsQuery, + ) -> Result { + self.data + .query_usage_analytics(query) + .await + .map_err(|err| match err { + aether_data_contracts::DataLayerError::InvalidInput(message) + if query.view == usage::UsageAnalyticsView::DashboardCharts => + { + GatewayError::Client { + status: http::StatusCode::UNPROCESSABLE_ENTITY, + message, + } + } + err => GatewayError::Internal(err.to_string()), + }) + } #[allow(dead_code)] pub(crate) async fn rebuild_api_key_usage_stats(&self) -> Result { self.data diff --git a/apps/aether-gateway/src/tests/control/admin/api_keys.rs b/apps/aether-gateway/src/tests/control/admin/api_keys.rs index fa84737e4..9870fcd8a 100644 --- a/apps/aether-gateway/src/tests/control/admin/api_keys.rs +++ b/apps/aether-gateway/src/tests/control/admin/api_keys.rs @@ -616,6 +616,7 @@ async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_princi let payload: serde_json::Value = response.json().await.expect("json body should parse"); assert_eq!(payload["name"], json!("standalone-key")); assert_eq!(payload["is_standalone"], json!(true)); + assert!(payload.get("credential_kind").is_none()); assert_eq!(payload["rate_limit"], serde_json::Value::Null); assert_eq!(payload["concurrent_limit"], serde_json::Value::Null); assert_eq!(payload["allowed_providers"], json!(["openai"])); @@ -646,6 +647,7 @@ async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_princi list_response.json().await.expect("list json should parse"); assert_eq!(list_payload["total"], json!(1)); assert_eq!(list_payload["api_keys"][0]["name"], json!("standalone-key")); + assert!(list_payload["api_keys"][0].get("credential_kind").is_none()); gateway_handle.abort(); upstream_handle.abort(); @@ -702,6 +704,7 @@ async fn gateway_handles_admin_api_keys_update_locally_with_trusted_admin_princi let payload: serde_json::Value = response.json().await.expect("json body should parse"); assert_eq!(payload["id"], json!("key-123")); assert_eq!(payload["name"], json!("renamed-key")); + assert!(payload.get("credential_kind").is_none()); assert_eq!(payload["rate_limit"], serde_json::Value::Null); assert_eq!(payload["concurrent_limit"], json!(12)); assert_eq!(payload["allowed_providers"], json!(["gemini"])); diff --git a/apps/aether-gateway/src/tests/control/admin/billing.rs b/apps/aether-gateway/src/tests/control/admin/billing.rs index e02476226..497fab81b 100644 --- a/apps/aether-gateway/src/tests/control/admin/billing.rs +++ b/apps/aether-gateway/src/tests/control/admin/billing.rs @@ -72,6 +72,138 @@ async fn send_admin_billing_request( request.send().await.expect("request should succeed") } +#[tokio::test] +async fn user_account_history_http_filters_wallet_and_plan_history() { + use aether_data::repository::{ + billing::{InMemoryBillingReadRepository, UserPlanEntitlementRecord}, + users::StoredUserAuthRecord, + wallet::{InMemoryWalletRepository, StoredWalletSnapshot}, + }; + + let users = ["user-1", "user-2"].map(|id| { + StoredUserAuthRecord::new( + id.to_string(), + Some(format!("{id}@example.com")), + true, + id.to_string(), + Some("hash".to_string()), + "user".to_string(), + "local".to_string(), + None, + None, + None, + true, + false, + None, + None, + ) + .expect("user should build") + }); + let wallets = ["user-1", "user-2"].map(|id| { + StoredWalletSnapshot::new( + format!("wallet-{id}"), + Some(id.to_string()), + None, + 12.5, + 2.5, + "finite".to_string(), + "USD".to_string(), + "active".to_string(), + 30.0, + 10.0, + 3.0, + 1.5, + 1_710_000_000, + ) + .expect("wallet should build") + }); + let now = chrono::Utc::now().timestamp().max(0) as u64; + let entitlements = [ + ("current", "user-1", "active"), + ("revoked", "user-1", "revoked"), + ("another-user", "user-2", "revoked"), + ] + .map(|(id, user_id, status)| UserPlanEntitlementRecord { + id: id.to_string(), + user_id: user_id.to_string(), + plan_id: "plan-1".to_string(), + payment_order_id: format!("order-{id}"), + status: status.to_string(), + starts_at_unix_secs: now - 60, + expires_at_unix_secs: now + 3600, + entitlements_snapshot: json!([]), + created_at_unix_secs: now - 60, + updated_at_unix_secs: now, + }); + let state = AppState::new().unwrap().with_data_state_for_tests( + GatewayDataState::with_user_billing_and_wallet_for_tests( + Arc::new(InMemoryUserReadRepository::seed_auth_users(users)), + Arc::new(InMemoryBillingReadRepository::seed_user_plan_entitlements( + entitlements, + )), + Arc::new(InMemoryWalletRepository::seed(wallets)), + ), + ); + let (url, handle) = start_server(build_router_with_state(state)).await; + let wallet_path = "/api/admin/wallets?user_id=user-1&owner_type=user&limit=1&offset=0"; + let response = send_admin_billing_request(&url, http::Method::GET, wallet_path, None).await; + assert_eq!(response.status(), StatusCode::OK); + let wallet: serde_json::Value = response.json().await.unwrap(); + assert_eq!(wallet["total"], 1); + assert_eq!(wallet["items"].as_array().unwrap().len(), 1); + assert_eq!(wallet["items"][0]["id"], "wallet-user-1"); + assert_eq!(wallet["items"][0]["user_id"], "user-1"); + + let response = send_admin_billing_request( + &url, + http::Method::GET, + "/api/admin/wallets?user_id=missing-user&limit=1", + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let missing: serde_json::Value = response.json().await.unwrap(); + assert_eq!(missing["total"], 0); + assert_eq!(missing["items"], json!([])); + + let path = "/api/admin/users/user-1/billing/entitlements"; + for query in ["", "?include_inactive=false"] { + let response = + send_admin_billing_request(&url, http::Method::GET, &format!("{path}{query}"), None) + .await; + assert_eq!(response.status(), StatusCode::OK); + let current: serde_json::Value = response.json().await.unwrap(); + assert_eq!(current["total"], 1); + assert_eq!(current["items"][0]["id"], "current"); + assert_eq!(current["items"][0]["active"], true); + } + let response = send_admin_billing_request( + &url, + http::Method::GET, + &format!("{path}?include_inactive=true"), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let history: serde_json::Value = response.json().await.unwrap(); + assert_eq!(history["total"], 2); + let items = history["items"].as_array().unwrap(); + assert!(items.iter().all(|item| item["user_id"] == "user-1")); + assert!(items + .iter() + .any(|item| item["id"] == "revoked" && item["active"] == false)); + + let response = send_admin_billing_request( + &url, + http::Method::GET, + &format!("{path}?include_inactive=invalid"), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + handle.abort(); +} + #[tokio::test] async fn gateway_handles_admin_billing_presets_locally_with_trusted_admin_principal() { let upstream_hits = Arc::new(Mutex::new(0usize)); @@ -657,3 +789,110 @@ async fn gateway_handles_admin_billing_collector_routes_locally_with_trusted_adm gateway_handle.abort(); upstream_handle.abort(); } + +#[tokio::test] +async fn provider_expense_http_contract_requires_admin_and_records_retries_only_once() { + use aether_data::repository::{ + billing::InMemoryBillingReadRepository, + provider_catalog::InMemoryProviderCatalogReadRepository, + }; + use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider; + let provider = StoredProviderCatalogProvider::new( + "provider-1".into(), + "=Supplier".into(), + None, + "custom".into(), + ) + .unwrap(); + let catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![provider], + vec![], + vec![], + )); + let state = AppState::new().unwrap().with_data_state_for_tests( + GatewayDataState::with_billing_reader_for_tests(Arc::new( + InMemoryBillingReadRepository::default(), + )) + .with_provider_catalog_reader(catalog), + ); + let (url, handle) = start_server(build_router_with_state(state)).await; + let path = "/api/admin/billing/provider-expenses"; + let client = reqwest::Client::new(); + assert!(matches!( + client + .get(format!("{url}{path}")) + .send() + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let payload = json!({"client_request_id":uuid::Uuid::new_v4().to_string(),"provider_id":"provider-1","kind":"subscription","amount":"12.30","currency":"USD","paid_at":"2026-09-20T00:00:00Z","period_start":"2026-09-20T00:00:00Z","period_end":"2026-10-20T00:00:00Z","note":"=SUM(1,2)"}); + let first = + send_admin_billing_request(&url, http::Method::POST, path, Some(payload.clone())).await; + assert_eq!(first.status(), StatusCode::OK); + let first: serde_json::Value = first.json().await.unwrap(); + assert_eq!(first["item"]["amount"], "12.30000000"); + let again = + send_admin_billing_request(&url, http::Method::POST, path, Some(payload.clone())).await; + assert_eq!(again.status(), StatusCode::OK); + let again: serde_json::Value = again.json().await.unwrap(); + assert_eq!(first["item"]["id"], again["item"]["id"]); + let mut conflict = payload.clone(); + conflict["amount"] = json!("13"); + assert_eq!( + send_admin_billing_request(&url, http::Method::POST, path, Some(conflict)) + .await + .status(), + StatusCode::CONFLICT + ); + let range = "?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z&limit=1&offset=5"; + let page = + send_admin_billing_request(&url, http::Method::GET, &format!("{path}{range}"), None).await; + assert_eq!(page.status(), StatusCode::OK); + let page: serde_json::Value = page.json().await.unwrap(); + assert_eq!(page["total"], 1); + assert_eq!(page["items"], json!([])); + assert_eq!(page["totals"][0]["subscription_amount"], "12.30000000"); + let csv = send_admin_billing_request( + &url, + http::Method::GET, + &format!("{path}{range}&format=csv"), + None, + ) + .await; + assert_eq!(csv.status(), StatusCode::OK); + let csv = csv.text().await.unwrap(); + assert!(csv.contains("'=Supplier")); + assert!(csv.contains("'=SUM(1,2)")); + assert!(csv.contains("12.30000000")); + let accounts = send_admin_billing_request( + &url, + http::Method::GET, + "/api/admin/billing/provider-accounts", + None, + ) + .await; + assert_eq!(accounts.status(), StatusCode::OK); + let accounts: serde_json::Value = accounts.json().await.unwrap(); + assert_eq!(accounts["items"][0]["provider_id"], "provider-1"); + assert!(accounts["items"][0]["balance"].is_null()); + let void_path = format!("{path}/{}/void", first["item"]["id"].as_str().unwrap()); + let voided = send_admin_billing_request(&url, http::Method::POST, &void_path, None).await; + assert_eq!(voided.status(), StatusCode::OK); + let voided: serde_json::Value = voided.json().await.unwrap(); + assert_eq!(voided["item"]["status"], "void"); + let again = send_admin_billing_request(&url, http::Method::POST, &void_path, None).await; + assert_eq!(again.status(), StatusCode::OK); + let again: serde_json::Value = again.json().await.unwrap(); + assert_eq!(voided, again); + let page: serde_json::Value = + send_admin_billing_request(&url, http::Method::GET, &format!("{path}{range}"), None) + .await + .json() + .await + .unwrap(); + assert_eq!(page["total"], 0); + assert_eq!(page["totals"], json!([])); + handle.abort(); +} diff --git a/apps/aether-gateway/src/tests/control/admin/health_access.rs b/apps/aether-gateway/src/tests/control/admin/health_access.rs index 36d50a955..b97c8ffa9 100644 --- a/apps/aether-gateway/src/tests/control/admin/health_access.rs +++ b/apps/aether-gateway/src/tests/control/admin/health_access.rs @@ -32,6 +32,107 @@ use crate::data::GatewayDataState; const ADMIN_ENDPOINT_HEALTH_DATA_UNAVAILABLE_DETAIL: &str = "Admin endpoint health data unavailable"; +#[tokio::test] +async fn health_v2_publication_requires_admin_and_public_projection_keeps_empty_objects() { + use aether_data::repository::usage::InMemoryUsageReadRepository; + + let data = GatewayDataState::with_usage_reader_for_tests(Arc::new( + InMemoryUsageReadRepository::seed(Vec::new()), + )) + .with_system_config_values_for_tests(vec![( + "health_publication_v1".to_string(), + json!({ "enabled": false, "objects": [] }), + )]); + let gateway = build_router_with_state(AppState::new().unwrap().with_data_state_for_tests(data)); + let (gateway_url, gateway_handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + let publication = json!({ "enabled": true, "objects": [ + {"public_id": "chat", "kind": "api_format", "value": "internal-format", "display_name": "Chat API"}, + {"public_id": "model", "kind": "model", "value": "internal-model", "display_name": "Model API"} + ]}); + let denied = client + .put(format!( + "{gateway_url}/api/admin/endpoints/health/v2/publication" + )) + .json(&publication) + .send() + .await + .unwrap(); + assert!(matches!( + denied.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let disabled = client + .get(format!("{gateway_url}/api/public/health/v2/objects")) + .send() + .await + .unwrap(); + assert_eq!(disabled.status(), StatusCode::NOT_FOUND); + + let saved = client + .put(format!( + "{gateway_url}/api/admin/endpoints/health/v2/publication" + )) + .header(GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin") + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123") + .json(&publication) + .send() + .await + .unwrap(); + assert_eq!(saved.status(), StatusCode::OK); + assert_eq!( + saved.json::().await.unwrap(), + publication + ); + + let public = client + .get(format!( + "{gateway_url}/api/public/health/v2/objects?kind=api_format&window=1h" + )) + .send() + .await + .unwrap(); + assert_eq!(public.status(), StatusCode::OK); + let body: serde_json::Value = public.json().await.unwrap(); + assert_eq!(body["data"]["total"], 1); + assert_eq!(body["data"]["items"][0]["id"], "chat"); + assert_eq!(body["data"]["items"][0]["status"], "unknown"); + assert_eq!(body["data"]["items"][0]["request_count"], 0); + assert!(body["data"]["items"][0]["service_availability"]["value"].is_null()); + let text = body.to_string(); + for forbidden in [ + "internal-format", + "internal-model", + "provider_id", + "source_value", + "attempts", + ] { + assert!( + !text.contains(forbidden), + "public projection leaked {forbidden}" + ); + } + let hidden = client + .get(format!( + "{gateway_url}/api/public/health/v2/objects/internal-format" + )) + .send() + .await + .unwrap(); + assert_eq!(hidden.status(), StatusCode::NOT_FOUND); + let internal_kind = client + .get(format!( + "{gateway_url}/api/public/health/v2/objects?kind=provider" + )) + .send() + .await + .unwrap(); + assert_eq!(internal_kind.status(), StatusCode::BAD_REQUEST); + gateway_handle.abort(); +} + async fn assert_admin_modules_status_with_smtp_password( stored_password: &str, notification_ready: bool, diff --git a/apps/aether-gateway/src/tests/control/admin/stats.rs b/apps/aether-gateway/src/tests/control/admin/stats.rs index 54d82d5e3..96da193bc 100644 --- a/apps/aether-gateway/src/tests/control/admin/stats.rs +++ b/apps/aether-gateway/src/tests/control/admin/stats.rs @@ -30,6 +30,8 @@ use crate::constants::{ }; use crate::data::GatewayDataState; +mod overview; + const DAY_0_UNIX_SECS: i64 = 1_710_913_600; const DAY_1_UNIX_SECS: i64 = 1_711_000_000; const DAY_2_UNIX_SECS: i64 = 1_711_086_400; diff --git a/apps/aether-gateway/src/tests/control/admin/stats/overview.rs b/apps/aether-gateway/src/tests/control/admin/stats/overview.rs new file mode 100644 index 000000000..92591ac31 --- /dev/null +++ b/apps/aether-gateway/src/tests/control/admin/stats/overview.rs @@ -0,0 +1,461 @@ +use super::*; + +const RANGE: &str = "from=2026-09-01T23:45:00Z&to=2026-09-02T00:15:00Z&timezone=Asia%2FShanghai"; + +#[tokio::test] +async fn overview_live_rates_use_only_the_last_sixty_seconds() { + let now = chrono::Utc::now().timestamp(); + let recent = sample_usage_row( + "live-recent", + "live-recent-request", + None, + None, + None, + "Provider", + "model-1", + 100, + 20, + 1.0, + 0.8, + now - 10, + ); + let historical = sample_usage_row( + "live-earlier", + "live-earlier-request", + None, + None, + None, + "Provider", + "model-1", + 1000, + 200, + 10.0, + 8.0, + now - 120, + ); + let repository = Arc::new(InMemoryUsageReadRepository::seed([recent, historical])); + let gateway = build_router_with_state( + AppState::new() + .unwrap() + .with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(repository)), + ); + let (url, handle) = start_server(gateway).await; + let response = admin_request( + reqwest::Client::new().get(format!("{url}/api/admin/overview/operations/live")), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = response.json().await.unwrap(); + let activity = &value["data"]["recent_activity"]["data"]; + assert_eq!(activity["window_seconds"], 60); + assert_eq!(activity["requests_per_minute"], 1); + assert_eq!(activity["tokens_per_minute"], 120); + assert_eq!(activity["requests_per_second"], 1.0 / 60.0); + handle.abort(); +} + +#[tokio::test] +async fn overview_dashboard_summary_reads_only_the_new_collection_and_all_current_users() { + let now = chrono::Utc::now(); + let since = now - chrono::Duration::minutes(1); + let mut recent = sample_usage_row( + "dashboard-new-row", + "dashboard-new-request", + Some("user-1"), + Some("key-1"), + Some("Personal"), + "Provider", + "model-1", + 100, + 20, + 1.0, + 0.8, + now.timestamp(), + ); + recent.request_metadata = Some(json!({"analytics_attribution":{"is_standalone":false}})); + let mut historical = recent.clone(); + historical.id = "dashboard-old-row".into(); + historical.request_id = "dashboard-old-request".into(); + historical.created_at_unix_ms = (now - chrono::Duration::days(500)).timestamp() as u64; + let repository = Arc::new( + InMemoryUsageReadRepository::seed([recent, historical]) + .with_dashboard_stats_since(since) + .with_analytics_users([ + sample_user_summary("user-1", "Alice", "user", true), + sample_user_summary("user-2", "Bob", "user", false), + ]), + ); + let gateway = build_router_with_state( + AppState::new() + .unwrap() + .with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(repository)), + ); + let (url, handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + let endpoint = format!("{url}/api/admin/overview/dashboard/summary"); + assert!(matches!( + client.get(&endpoint).send().await.unwrap().status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let response = admin_request(client.get(format!("{endpoint}?timezone=UTC"))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let value: serde_json::Value = response.json().await.unwrap(); + assert_eq!(value["stats_since"], since.to_rfc3339()); + assert_eq!(value["today"]["request_count"], 1); + assert_eq!(value["total"]["request_count"], 1); + assert_eq!(value["today"]["input_tokens"], 100); + assert_eq!(value["today"]["output_tokens"], 20); + assert_eq!(value["total"]["total_tokens"], 120); + assert_eq!(value["total"]["billable_amount"]["value"], "0.80000000"); + assert_eq!(value["users"]["total"], 2); + assert_eq!(value["active_days"], 1); + assert_eq!(value["concurrency"]["scope"], "node"); + assert!(value["today"].get("latency_p95_ms").is_none()); + let invalid = admin_request(client.get(format!("{endpoint}?timezone=invalid"))) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + handle.abort(); +} + +#[tokio::test] +async fn overview_dashboard_keeps_today_and_lifetime_totals_separate() { + let now = chrono::Utc::now(); + let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); + let row = sample_usage_row( + "today-row", + "today-request", + Some("user-1"), + Some("key-1"), + Some("Personal"), + "Provider", + "model-1", + 100, + 20, + 1.0, + 0.8, + now.timestamp() + .saturating_sub(1) + .max(today_start.timestamp()), + ); + let mut historical = row.clone(); + historical.id = "historical-row".into(); + historical.request_id = "historical-request".into(); + historical.created_at_unix_ms = (now - chrono::Duration::days(500)).timestamp() as u64; + let mut future = row.clone(); + future.id = "future-row".into(); + future.request_id = "future-request".into(); + future.created_at_unix_ms = (now + chrono::Duration::days(1)).timestamp() as u64; + let repository = Arc::new( + InMemoryUsageReadRepository::seed([row, historical, future]).with_analytics_users([ + sample_user_summary("user-1", "Alice", "user", true), + sample_user_summary("user-2", "Bob", "user", true), + ]), + ); + let gateway = build_router_with_state( + AppState::new() + .unwrap() + .with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(repository)), + ); + let (url, handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + let endpoint = format!("{url}/api/admin/overview/dashboard"); + let anonymous = client.get(&endpoint).send().await.unwrap(); + assert!(matches!( + anonymous.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let response = admin_request(client.get(format!("{endpoint}?timezone=UTC"))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers()[http::header::CACHE_CONTROL] + .to_str() + .unwrap() + .contains("no-store")); + let data: serde_json::Value = response.json().await.unwrap(); + assert_eq!(data["today"]["data"]["request_count"], 1); + assert_eq!(data["total"]["data"]["request_count"], 2); + assert_eq!(data["today"]["data"]["total_tokens"], 120); + assert_eq!(data["total"]["data"]["total_tokens"], 240); + assert_eq!( + data["today"]["data"]["billable_amount"]["value"], + "0.80000000" + ); + assert_eq!( + data["total"]["data"]["billable_amount"]["value"], + "1.60000000" + ); + assert_eq!(data["today"]["data"]["enabled_users"], 2); + assert_eq!(data["today"]["meta"]["range"]["timezone"], "UTC"); + assert_eq!(data["total"]["meta"]["range"]["period"], "all_time"); + assert_eq!( + data["today"]["meta"]["read_revision"], + data["total"]["meta"]["read_revision"] + ); + assert_eq!( + data["today"]["meta"]["range"]["to"], + data["total"]["meta"]["range"]["to"] + ); + let total_endpoint = format!("{endpoint}/total"); + let anonymous_total = client.get(&total_endpoint).send().await.unwrap(); + assert!(matches!( + anonymous_total.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let pending = admin_request(client.get(format!("{total_endpoint}?timezone=UTC"))) + .send() + .await + .unwrap(); + assert_eq!(pending.status(), StatusCode::ACCEPTED); + assert_eq!( + pending.json::().await.unwrap()["status"], + "pending" + ); + let ready = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let response = + admin_request(client.get(format!("{total_endpoint}?timezone=Asia%2FShanghai"))) + .send() + .await + .unwrap(); + if response.status() == StatusCode::OK { + break response.json::().await.unwrap(); + } + assert_eq!(response.status(), StatusCode::ACCEPTED); + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(ready["status"], "ready"); + assert_eq!(ready["stale"], false); + assert_eq!(ready["total"]["data"]["request_count"], 2); + assert_eq!(ready["total"]["data"]["total_tokens"], 240); + assert_eq!( + ready["total"]["data"]["billable_amount"]["value"], + "1.60000000" + ); + assert_eq!(ready["total"]["meta"]["range"]["timezone"], "Asia/Shanghai"); + let cached_utc = admin_request(client.get(format!("{total_endpoint}?timezone=UTC"))) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!( + cached_utc["total"]["meta"]["read_revision"], + ready["total"]["meta"]["read_revision"] + ); + assert_eq!( + cached_utc["total"]["meta"]["generated_at"], + ready["total"]["meta"]["generated_at"] + ); + for query in [ + "from=2020-01-01T00:00:00Z", + "user_id=user-1", + "timezone=invalid", + "timezone=UTC&timezone=UTC", + ] { + let response = admin_request(client.get(format!("{endpoint}?{query}"))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{query}"); + } + handle.abort(); +} + +#[tokio::test] +async fn overview_preserves_scope_pagination_amounts_and_csv_across_precise_range() { + let from = chrono::DateTime::parse_from_rfc3339("2026-09-01T23:45:00Z") + .unwrap() + .timestamp(); + let mut row = sample_usage_row( + "row-1", + "request-1", + Some("user-1"), + Some("key-1"), + Some("Personal"), + "Provider", + "model-1", + 100, + 20, + 1.0, + 0.8, + from + 60, + ); + row.request_metadata = + Some(json!({"analytics_attribution": {"is_standalone": false, "record_kind": "request"}})); + let mut outside = row.clone(); + outside.id = "row-outside".into(); + outside.request_id = "request-outside".into(); + outside.created_at_unix_ms = (from + 30 * 60) as u64; + let repository = Arc::new( + InMemoryUsageReadRepository::seed([row, outside]).with_analytics_users([ + sample_user_summary("user-1", "Alice", "user", true), + sample_user_summary("user-2", "Bob", "user", true), + ]), + ); + let gateway = build_router_with_state( + AppState::new() + .unwrap() + .with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(repository)), + ); + let (url, handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + let anonymous = client + .get(format!("{url}/api/admin/overview/summary?{RANGE}")) + .send() + .await + .unwrap(); + assert!(matches!( + anonymous.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + + let summary = admin_request(client.get(format!("{url}/api/admin/overview/summary?{RANGE}"))) + .send() + .await + .unwrap(); + assert_eq!(summary.status(), StatusCode::OK); + assert!(summary + .headers() + .get(http::header::CACHE_CONTROL) + .unwrap() + .to_str() + .unwrap() + .contains("no-store")); + let summary: serde_json::Value = summary.json().await.unwrap(); + assert_eq!(summary["data"]["request_count"], 1); + assert_eq!(summary["data"]["billable_amount"]["value"], "0.80000000"); + assert_eq!(summary["data"]["quota_covered_amount"]["status"], "unknown"); + assert_eq!(summary["meta"]["range"]["time_basis"], "request_started_at"); + + let charts = admin_request(client.get(format!( + "{url}/api/admin/overview/dashboard/charts?{RANGE}&granularity=day" + ))) + .send() + .await + .unwrap(); + assert_eq!(charts.status(), StatusCode::OK); + let charts: serde_json::Value = charts.json().await.unwrap(); + assert_eq!(charts["data"]["summary"]["request_count"], 1); + assert_eq!(charts["data"]["series"].as_array().unwrap().len(), 1); + assert_eq!(charts["data"]["models"][0]["id"], "model-1"); + assert_eq!( + charts["data"]["models"][0]["billable_amount"]["value"], + "0.80000000" + ); + assert_eq!( + charts["data"]["providers"][0]["billable_amount"]["value"], + "0.80000000" + ); + + let users = admin_request(client.get(format!( + "{url}/api/admin/overview/users?{RANGE}&limit=1&offset=1&sort=request_count" + ))) + .send() + .await + .unwrap(); + assert_eq!(users.status(), StatusCode::OK); + let users: serde_json::Value = users.json().await.unwrap(); + assert_eq!(users["data"]["total"], 2); + assert_eq!(users["data"]["items"][0]["user_id"], "user-2"); + assert_eq!(users["data"]["items"][0]["request_count"], 0); + assert_eq!(users["data"]["summary"]["user_count"], 2); + assert_eq!(users["data"]["summary"]["active_user_count"], 1); + assert_eq!(users["data"]["summary"]["request_count"], 1); + assert_eq!( + users["data"]["summary"]["billable_amount"]["value"], + "0.80000000" + ); + assert!(users["data"]["finance_summary"].is_null()); + assert!(users["data"]["items"][0]["finance"].is_null()); + + let csv = admin_request(client.get(format!( + "{url}/api/admin/overview/users?{RANGE}&format=csv&limit=1" + ))) + .send() + .await + .unwrap(); + assert_eq!(csv.status(), StatusCode::OK); + let csv = csv.text().await.unwrap(); + assert!(csv.contains("Alice") && csv.contains("Bob")); + assert!(csv.contains("finance.recharge_amount.value")); + assert!(csv.contains("finance.plan_purchase_amount.value")); + + let detail = + admin_request(client.get(format!("{url}/api/admin/overview/users/user-1?{RANGE}"))) + .send() + .await + .unwrap(); + assert_eq!(detail.status(), StatusCode::OK); + let detail: serde_json::Value = detail.json().await.unwrap(); + assert_eq!(detail["meta"]["scope"]["kind"], "credential_owner"); + assert_eq!(detail["data"]["summary"]["request_count"], 1); + assert!(detail["data"]["finance"].is_null()); + assert!(detail["data"]["payments"].is_null()); + + let conflicting = admin_request(client.get(format!( + "{url}/api/admin/overview/users/user-1?{RANGE}&user_id=user-2&payment_limit=2" + ))) + .send() + .await + .unwrap(); + assert_eq!(conflicting.status(), StatusCode::BAD_REQUEST); + + let consumption = admin_request(client.get(format!( + "{url}/api/admin/overview/consumption?{RANGE}&status=success&sort=started_at" + ))) + .send() + .await + .unwrap(); + assert_eq!(consumption.status(), StatusCode::OK); + let consumption: serde_json::Value = consumption.json().await.unwrap(); + assert_eq!(consumption["data"]["items"][0]["id"], "row-1"); + assert_eq!(consumption["data"]["items"][0]["request_id"], "request-1"); + + for (filter, expected) in [ + ( + "api_key_id=key-1&provider_id=provider-1&request_id=request-1&status=success", + 1, + ), + ("api_key_id=other-key", 0), + ("user_id=user-1&attribution_kind=employee", 1), + ("user_id=user-2&attribution_kind=employee", 0), + ("provider_id=other-provider", 0), + ("slow_threshold_ms=5000", 0), + ( + "slow_threshold_ms=350&is_stream=false&endpoint_kind=chat", + 1, + ), + ] { + let records = admin_request(client.get(format!( + "{url}/api/admin/usage/records?{RANGE}&{filter}&include_total=true" + ))) + .send() + .await + .unwrap(); + assert_eq!(records.status(), StatusCode::OK, "filter: {filter}"); + let records: serde_json::Value = records.json().await.unwrap(); + assert_eq!(records["total"], expected, "filter: {filter}"); + } + + let invalid = admin_request(client.get(format!( + "{url}/api/admin/overview/summary?{RANGE}&made_up=1" + ))) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + handle.abort(); +} diff --git a/apps/aether-gateway/src/tests/control/admin/system.rs b/apps/aether-gateway/src/tests/control/admin/system.rs index a60973a96..673f8ccf8 100644 --- a/apps/aether-gateway/src/tests/control/admin/system.rs +++ b/apps/aether-gateway/src/tests/control/admin/system.rs @@ -1181,6 +1181,12 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr .as_deref() .is_some_and(|value| value.starts_with("aether-auth-api-key-secret-v2:")))); assert_eq!(recovery_payload["version"], "1.5"); + assert!(recovery_payload["users"][0]["api_keys"][0] + .get("credential_kind") + .is_none()); + assert!(recovery_payload["standalone_keys"][0] + .get("credential_kind") + .is_none()); assert_eq!(recovery_payload["users"][0]["password_hash"], "argon2-hash"); assert_eq!( recovery_payload["users"][0]["api_keys"][0]["key_hash"], @@ -1228,6 +1234,10 @@ async fn gateway_handles_admin_system_users_export_locally_with_trusted_admin_pr ); let payload: serde_json::Value = response.json().await.expect("json body should parse"); assert_eq!(payload["version"], "1.6"); + assert!(payload["users"][0]["api_keys"][0] + .get("credential_kind") + .is_none()); + assert!(payload["standalone_keys"][0].get("credential_kind").is_none()); assert!(payload["exported_at"].as_str().is_some()); assert_eq!(payload["user_groups"][0]["name"], "Restricted GPT"); assert!(payload["user_groups"][0].get("priority").is_none()); diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support.rs b/apps/aether-gateway/src/tests/frontdoor/public_support.rs index 480222b4f..42ca9f64a 100644 --- a/apps/aether-gateway/src/tests/frontdoor/public_support.rs +++ b/apps/aether-gateway/src/tests/frontdoor/public_support.rs @@ -52,11 +52,193 @@ const TEST_EMAIL_VERIFICATION_TOKEN: &str = #[path = "public_support/auth_cookie.rs"] mod auth_cookie; +#[path = "public_support/announcement_user_list.rs"] +mod announcement_user_list; #[path = "public_support/dashboard.rs"] mod dashboard; #[path = "public_support/vscodex.rs"] mod vscodex; +#[tokio::test] +async fn health_v2_public_scope_filters_summary_lists_and_details_before_projection() { + let now = Utc::now(); + let mut published = sample_user_usage_audit( + "private-detail-id", + "private-request-id", + "private-user-id", + "internal-published-model", + "private-provider-name", + "failed", + now - chrono::Duration::minutes(5), + ); + published.request_metadata = Some(json!({ + "analytics_failure": {"origin": "upstream", "stage": "response", "reason": "private-diagnostic", "schema_version": 1}, + })); + let hidden = sample_user_usage_audit( + "private-hidden-id", + "private-hidden-request", + "private-user-id", + "unpublished-model", + "private-provider-name", + "completed", + now - chrono::Duration::minutes(4), + ); + let data = GatewayDataState::with_usage_reader_for_tests(Arc::new(InMemoryUsageReadRepository::seed(vec![published, hidden]))) + .with_system_config_values_for_tests(vec![("health_publication_v1".into(), json!({ + "enabled": true, "objects": [ + {"public_id": "model-api", "kind": "model", "value": "internal-published-model", "display_name": "Model API"}, + ], + }))]); + let (gateway_url, gateway_handle) = start_server(build_router_with_state( + AppState::new().unwrap().with_data_state_for_tests(data), + )) + .await; + let client = reqwest::Client::new(); + for resource in ["summary", "objects", "objects/model-api"] { + let response = client + .get(format!( + "{gateway_url}/api/public/health/v2/{resource}?kind=model" + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await.unwrap(); + let metrics = match resource { + "summary" => &body["data"]["requests"], + "objects" => &body["data"]["items"][0], + _ => &body["data"], + }; + assert_eq!( + metrics["request_count"], 1, + "{resource} includes only the published model" + ); + if resource == "objects" { + assert_eq!(body["data"]["total"], 1); + assert!(!metrics["timeline"].as_array().unwrap().is_empty()); + } + let encoded = body.to_string(); + for forbidden in [ + "private-", + "internal-published-model", + "unpublished-model", + "provider_id", + "api_key_id", + "source_value", + "attempts", + "error_message", + "analytics_failure", + ] { + assert!( + !encoded.contains(forbidden), + "{resource} leaked {forbidden}" + ); + } + } + let empty_scope = client + .get(format!( + "{gateway_url}/api/public/health/v2/summary?kind=api_format" + )) + .send() + .await + .unwrap(); + assert_eq!(empty_scope.status(), StatusCode::OK); + let empty: serde_json::Value = empty_scope.json().await.unwrap(); + assert_eq!(empty["data"]["object_count"], 0); + assert_eq!(empty["data"]["requests"]["request_count"], 0); + gateway_handle.abort(); +} + +#[tokio::test] +async fn health_v2_authenticated_user_access_does_not_publish_anonymous_status() { + let now = Utc::now(); + let user = sample_auth_user(now); + let access_token = build_test_auth_token( + "access", + serde_json::Map::from_iter([ + ("user_id".into(), json!(user.id)), + ("role".into(), json!(user.role)), + ( + "created_at".into(), + json!(user.created_at.map(|value| value.to_rfc3339())), + ), + ("session_id".into(), json!("health-user-session")), + ]), + now + chrono::Duration::hours(1), + ); + let data = GatewayDataState::with_usage_reader_for_tests(Arc::new( + InMemoryUsageReadRepository::seed(vec![sample_user_usage_audit( + "health-user-row", + "health-user-request", + &user.id, + "model-health", + "internal-provider", + "completed", + now - chrono::Duration::minutes(5), + )]), + )) + .with_user_reader(Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![ + user, + ]))) + .with_provider_catalog_reader(Arc::new(InMemoryProviderCatalogReadRepository::seed( + Vec::new(), + Vec::new(), + Vec::new(), + ))) + .with_system_config_values_for_tests(vec![( + "health_publication_v1".into(), + json!({ "enabled": false, "objects": [] }), + )]); + let state = AppState::new() + .unwrap() + .with_data_state_for_tests(data) + .with_auth_session_for_tests(sample_auth_session( + "user-auth-1", + "health-user-session", + "health-user-device", + "refresh-token-placeholder", + now, + )); + let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await; + let client = reqwest::Client::new(); + let url = format!("{gateway_url}/api/users/me/health/v2/objects?kind=api_format"); + let anonymous = client.get(&url).send().await.unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + let authenticated = client + .get(&url) + .bearer_auth(&access_token) + .header("x-client-device-id", "health-user-device") + .header("user-agent", "AetherTest/1.0") + .send() + .await + .unwrap(); + assert_eq!(authenticated.status(), StatusCode::OK); + let body: serde_json::Value = authenticated.json().await.unwrap(); + assert_eq!(body["meta"]["scope"]["kind"], "authenticated"); + assert_eq!(body["data"]["total"], 1); + assert_eq!(body["data"]["items"][0]["request_count"], 1); + assert!(body["data"]["items"][0].get("attempts").is_none()); + assert!(!body.to_string().contains("internal-provider")); + let provider = client + .get(format!( + "{gateway_url}/api/users/me/health/v2/objects?kind=provider" + )) + .bearer_auth(&access_token) + .header("x-client-device-id", "health-user-device") + .header("user-agent", "AetherTest/1.0") + .send() + .await + .unwrap(); + assert_eq!(provider.status(), StatusCode::BAD_REQUEST); + let public = client + .get(format!("{gateway_url}/api/public/health/v2/objects")) + .send() + .await + .unwrap(); + assert_eq!(public.status(), StatusCode::NOT_FOUND); + gateway_handle.abort(); +} + #[tokio::test] async fn gateway_handles_public_announcements_list_without_proxying_upstream() { let upstream_hits = Arc::new(Mutex::new(0usize)); @@ -7806,6 +7988,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre .expect("created id should be string") .to_string(); assert_eq!(create_payload["name"], "writer-key"); + assert!(create_payload.get("credential_kind").is_none()); assert_eq!(create_payload["rate_limit"], 120); assert_eq!(create_payload["concurrent_limit"], serde_json::Value::Null); assert_eq!( @@ -7847,6 +8030,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre .await .expect("json body should parse"); assert_eq!(update_payload["name"], "writer-key-renamed"); + assert!(update_payload.get("credential_kind").is_none()); assert_eq!(update_payload["rate_limit"], 30); assert_eq!(update_payload["concurrent_limit"], 4); assert_eq!( diff --git a/apps/aether-gateway/src/tests/frontdoor/public_support/announcement_user_list.rs b/apps/aether-gateway/src/tests/frontdoor/public_support/announcement_user_list.rs new file mode 100644 index 000000000..4f0627597 --- /dev/null +++ b/apps/aether-gateway/src/tests/frontdoor/public_support/announcement_user_list.rs @@ -0,0 +1,317 @@ +use super::*; + +struct AnnouncementUserFixture { + url: String, + token: String, + device: String, + client: reqwest::Client, + upstream_hits: Arc>, + gateway: tokio::task::JoinHandle<()>, + upstream: tokio::task::JoinHandle<()>, +} + +impl AnnouncementUserFixture { + async fn start( + user_id: &str, + role: &str, + repository: Arc, + ) -> Self { + let now = Utc::now(); + let mut user = sample_auth_user(now); + user.id = user_id.into(); + user.role = role.into(); + let session = format!("{user_id}-session"); + let device = format!("{user_id}-device"); + let token = build_test_auth_token( + "access", + serde_json::Map::from_iter([ + ("user_id".into(), json!(user.id)), + ("role".into(), json!(user.role)), + ( + "created_at".into(), + json!(user.created_at.map(|value| value.to_rfc3339())), + ), + ("session_id".into(), json!(session)), + ]), + now + chrono::Duration::hours(1), + ); + let (url, upstream_hits, gateway, upstream) = start_auth_announcement_gateway_with_state( + user, + sample_auth_wallet(user_id, now), + [sample_auth_session( + user_id, + &session, + &device, + "refresh-placeholder", + now, + )], + repository, + ) + .await; + Self { + url, + token, + device, + client: reqwest::Client::new(), + upstream_hits, + gateway, + upstream, + } + } + + fn auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + request + .bearer_auth(&self.token) + .header("x-client-device-id", &self.device) + .header("user-agent", "AetherTest/1.0") + } + + async fn list(&self, suffix: &str) -> serde_json::Value { + let response = self + .auth( + self.client + .get(format!("{}/api/announcements/users/me{suffix}", self.url)), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{suffix}"); + response.json().await.unwrap() + } + + async fn mark_read(&self, id: &str) { + let response = self + .auth( + self.client + .patch(format!("{}/api/announcements/{id}/read-status", self.url)), + ) + .json(&json!({ "is_read": true })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + async fn read_all(&self) { + let response = self + .auth( + self.client + .post(format!("{}/api/announcements/read-all", self.url)), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } +} + +impl Drop for AnnouncementUserFixture { + fn drop(&mut self) { + self.gateway.abort(); + self.upstream.abort(); + } +} + +fn announcement(id: &str, pinned: bool, priority: i32, created_at: i64) -> StoredAnnouncement { + StoredAnnouncement::new( + id.into(), + format!("Title {id}"), + format!("Content {id}"), + "info".into(), + priority, + true, + pinned, + false, + Some("author-1".into()), + Some("Author".into()), + None, + None, + created_at, + created_at, + ) + .unwrap() +} + +fn ids(payload: &serde_json::Value) -> Vec<&str> { + payload["items"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_str().unwrap()) + .collect() +} + +#[tokio::test] +async fn announcement_user_list_keeps_visible_order_and_global_unread_counts_across_pages() { + let now = Utc::now().timestamp(); + let mut draft = announcement("draft", true, 999, now); + draft.is_active = false; + let mut future = announcement("future", true, 999, now); + future.start_time_unix_secs = Some((now + 3600) as u64); + let mut expired = announcement("expired", true, 999, now); + expired.end_time_unix_secs = Some((now - 3600) as u64); + let repository = Arc::new(InMemoryAnnouncementReadRepository::seed_with_reads( + vec![ + announcement("active-e", false, 100, now - 20), + announcement("active-b", true, 10, now - 100), + draft, + announcement("active-d", false, 100, now - 10), + future, + announcement("active-c", true, 5, now - 50), + expired, + announcement("active-a", true, 10, now - 100), + ], + [("list-user".into(), "active-b".into())], + )); + let fixture = AnnouncementUserFixture::start("list-user", "user", repository).await; + let all = fixture.list("").await; + assert_eq!( + ids(&all), + ["active-a", "active-b", "active-c", "active-d", "active-e"] + ); + assert_eq!(all["total"], 5); + assert_eq!(all["unread_count"], 4); + assert_eq!(all["limit"], 20); + assert_eq!(all["offset"], 0); + assert_eq!(all["items"][0]["is_read"], false); + assert_eq!(all["items"][1]["is_read"], true); + assert_eq!(all["items"][0]["content"], "Content active-a"); + assert_eq!(all["items"][0]["author"]["username"], "Author"); + + let page = fixture.list("?limit=2&offset=1&unread_only=false").await; + assert_eq!(ids(&page), ["active-b", "active-c"]); + assert_eq!(page["total"], 5); + assert_eq!(page["unread_count"], 4); + assert_eq!(page["limit"], 2); + assert_eq!(page["offset"], 1); + let unread = fixture.list("?limit=2&offset=1&unread_only=true").await; + assert_eq!(ids(&unread), ["active-c", "active-d"]); + assert_eq!(unread["total"], 4); + assert_eq!(unread["unread_count"], 4); + for (query, total) in [ + ("?limit=2&offset=999", 5), + ("?limit=2&offset=999&unread_only=true", 4), + ] { + let outside = fixture.list(query).await; + assert!(ids(&outside).is_empty()); + assert_eq!(outside["total"], total); + assert_eq!(outside["unread_count"], 4); + } + + fixture.mark_read("active-d").await; + let unread = fixture.list("?unread_only=true").await; + assert_eq!(ids(&unread), ["active-a", "active-c", "active-e"]); + assert_eq!(unread["total"], 3); + assert_eq!(unread["unread_count"], 3); + let all = fixture.list("").await; + assert_eq!(all["items"][3]["is_read"], true); + let badge = fixture + .auth(fixture.client.get(format!( + "{}/api/announcements/users/me/unread-count", + fixture.url + ))) + .send() + .await + .unwrap(); + assert_eq!(badge.status(), StatusCode::OK); + assert_eq!( + badge.json::().await.unwrap()["unread_count"], + 3 + ); + fixture.read_all().await; + let all = fixture.list("").await; + assert_eq!(all["total"], 5); + assert_eq!(all["unread_count"], 0); + assert!(all["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["is_read"] == true)); + let unread = fixture.list("?unread_only=true").await; + assert!(ids(&unread).is_empty()); + assert_eq!(unread["total"], 0); + assert_eq!(*fixture.upstream_hits.lock().unwrap(), 0); +} + +#[tokio::test] +async fn announcement_user_list_is_personal_for_user_admin_and_audit_admin() { + let now = Utc::now().timestamp(); + let repository = Arc::new(InMemoryAnnouncementReadRepository::seed(vec![ + announcement("shared-notice", false, 1, now), + announcement("second-notice", false, 0, now), + ])); + for role in ["user", "admin", "audit_admin"] { + let fixture = AnnouncementUserFixture::start( + &format!("announcement-{role}"), + role, + Arc::clone(&repository), + ) + .await; + let initial = fixture.list("").await; + assert_eq!(initial["total"], 2, "{role}"); + assert_eq!( + initial["unread_count"], 2, + "another user's reads must not affect {role}" + ); + assert!(initial["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["is_read"] == false)); + fixture.mark_read("shared-notice").await; + let changed = fixture.list("").await; + assert_eq!(changed["unread_count"], 1, "{role}"); + assert_eq!(changed["items"][0]["is_read"], true); + fixture.read_all().await; + assert_eq!(fixture.list("").await["unread_count"], 0, "{role}"); + assert_eq!(*fixture.upstream_hits.lock().unwrap(), 0); + } +} + +#[tokio::test] +async fn announcement_user_list_requires_auth_and_rejects_invalid_filters() { + let fixture = AnnouncementUserFixture::start( + "announcement-validation", + "user", + Arc::new(InMemoryAnnouncementReadRepository::seed(Vec::new())), + ) + .await; + let url = format!("{}/api/announcements/users/me", fixture.url); + assert_eq!( + fixture.client.get(&url).send().await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + for query in [ + "limit=0", + "limit=101", + "limit=-1", + "limit=1.5", + "limit=", + "offset=-1", + "offset=1.5", + "offset=9223372036854775808", + "offset=18446744073709551616", + "unread_only=invalid", + "unread_only=", + "limit=20&limit=20", + "offset=0&offset=0", + "unread_only=false&unread_only=false", + "active_only=false", + "user_id=another-user", + "now=4102444800", + ] { + let response = fixture + .auth(fixture.client.get(format!("{url}?{query}"))) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{query}"); + } + let empty = fixture.list("/?limit=100&offset=9223372036854775807").await; + assert!(ids(&empty).is_empty()); + assert_eq!(empty["total"], 0); + assert_eq!(empty["unread_count"], 0); + assert_eq!(empty["limit"], 100); + assert_eq!(empty["offset"], i64::MAX); + assert_eq!(*fixture.upstream_hits.lock().unwrap(), 0); +} diff --git a/apps/aether-gateway/src/usage/reporting/context.rs b/apps/aether-gateway/src/usage/reporting/context.rs index 8c81d8ca1..5e8f5b92e 100644 --- a/apps/aether-gateway/src/usage/reporting/context.rs +++ b/apps/aether-gateway/src/usage/reporting/context.rs @@ -39,6 +39,7 @@ const INTERNAL_REPORT_OBSERVATION_FIELDS: &[&str] = &[ "client_response_headers", "upstream_response", "error_flow", + "analytics_failure", "transport_error", "input_tokens", "cache_creation_input_tokens", diff --git a/apps/aether-gateway/src/usage/reporting/failure.rs b/apps/aether-gateway/src/usage/reporting/failure.rs new file mode 100644 index 000000000..d9e9ca591 --- /dev/null +++ b/apps/aether-gateway/src/usage/reporting/failure.rs @@ -0,0 +1,291 @@ +use aether_usage_runtime::{ + stream_report_missing_terminal_event, stream_report_represents_failure, + sync_report_represents_failure, GatewayStreamReportRequest, GatewaySyncReportRequest, +}; +use serde_json::{json, Map, Value}; + +pub(crate) fn execution_error_analytics_context( + context: Option<&Value>, + error: &aether_contracts::ExecutionError, +) -> Option { + use aether_contracts::{ExecutionErrorKind as Kind, ExecutionPhase as Phase}; + let stage = match error.phase { + Phase::Connect => "connect", + Phase::Handshake => "handshake", + Phase::Write => "request_write", + Phase::FirstByte => "first_byte", + Phase::StreamRead => "stream_read", + Phase::Decode => "decode", + Phase::Finalize => "finalize", + }; + let (origin, reason) = match error.kind { + Kind::ConnectTimeout => ("transport", "connect_timeout"), + Kind::FirstByteTimeout => ("upstream", "first_byte_timeout"), + Kind::ReadTimeout => ("transport", "read_timeout"), + Kind::TlsError => ("transport", "tls_error"), + Kind::ProxyError => ("transport", "proxy_error"), + Kind::Upstream4xx | Kind::Upstream5xx => ("upstream", "upstream_response_error"), + Kind::ProtocolError => ("upstream", "protocol_error"), + Kind::Internal => ("gateway", "execution_internal_error"), + Kind::Cancelled => ("unknown", "execution_cancelled"), + }; + with_analytics_failure(context, origin, stage, reason) +} + +pub(crate) fn with_analytics_failure( + context: Option<&Value>, + origin: &'static str, + stage: &'static str, + reason: &'static str, +) -> Option { + let mut object = context + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + object.insert( + "analytics_failure".into(), + json!({ + "origin": origin, "stage": stage, "reason": reason, "schema_version": 1, + }), + ); + Some(Value::Object(object)) +} + +fn normalized_failure_context(context: Option<&Value>, failed: bool) -> Map { + let mut object = context + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if !failed { + // A successful retry supersedes a previous candidate's failure classification. + object.remove("analytics_failure"); + object.remove("error_flow"); + object.remove("transport_error"); + } + object +} + +fn classify_observed_failure(object: &Map, stage: &'static str) -> Option { + if object + .get("analytics_failure") + .and_then(Value::as_object) + .is_some() + { + return Some(Value::Object(object.clone())); + } + if object.get("transport_error").and_then(Value::as_bool) == Some(true) { + return with_analytics_failure( + Some(&Value::Object(object.clone())), + "transport", + stage, + "upstream_transport_error", + ); + } + if object.get("error_flow").is_some_and(|flow| { + flow.get("source").and_then(Value::as_str) == Some("upstream_response") + && flow + .get("status_code") + .and_then(Value::as_u64) + .is_some_and(|status| status >= 400) + }) || object + .get("upstream_response") + .and_then(|value| value.get("status_code")) + .and_then(Value::as_u64) + .is_some_and(|status| status >= 400) + { + return with_analytics_failure( + Some(&Value::Object(object.clone())), + "upstream", + stage, + "upstream_response_error", + ); + } + None +} + +pub(crate) fn sync_analytics_context( + context: Option<&Value>, + payload: &GatewaySyncReportRequest, +) -> Option { + let failed = sync_report_represents_failure(payload, None); + let object = normalized_failure_context(context, failed); + if !failed { + return Some(Value::Object(object)); + } + classify_observed_failure(&object, "response").or(Some(Value::Object(object))) +} + +pub(crate) fn stream_analytics_context( + context: Option<&Value>, + payload: &GatewayStreamReportRequest, + downstream_cancelled: bool, +) -> Option { + if downstream_cancelled { + return with_analytics_failure(context, "client", "delivery", "downstream_disconnect"); + } + let failed = stream_report_represents_failure(payload); + let object = normalized_failure_context(context, failed); + if !failed { + return Some(Value::Object(object)); + } + if let Some(classified) = classify_observed_failure(&object, "stream_read") { + return Some(classified); + } + if payload + .terminal_summary + .as_ref() + .is_some_and(|summary| summary.parser_error.is_some()) + { + return with_analytics_failure( + Some(&Value::Object(object)), + "gateway", + "decode", + "response_decode_error", + ); + } + if stream_report_missing_terminal_event(payload) { + return with_analytics_failure( + Some(&Value::Object(object)), + "upstream", + "stream_read", + "missing_terminal_event", + ); + } + Some(Value::Object(object)) +} + +pub(crate) fn gateway_error_analytics_context( + context: Option<&Value>, + error: &crate::GatewayError, +) -> Option { + match error { + crate::GatewayError::AdmissionTimeout { .. } => { + with_analytics_failure(context, "gateway", "admission", "gateway_admission_timeout") + } + crate::GatewayError::LocalExecutionPlanningTimeout { .. } => { + with_analytics_failure(context, "gateway", "routing", "planning_timeout") + } + crate::GatewayError::PlanUsageLimited(_) => { + with_analytics_failure(context, "client", "admission", "quota_exceeded") + } + crate::GatewayError::UpstreamUnavailable { .. } => { + with_analytics_failure(context, "upstream", "connect", "upstream_unavailable") + } + crate::GatewayError::ControlUnavailable { .. } => { + with_analytics_failure(context, "gateway", "routing", "control_unavailable") + } + crate::GatewayError::Internal(_) => { + with_analytics_failure(context, "gateway", "finalize", "internal_error") + } + _ => context.cloned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn sync_payload(status: u16) -> GatewaySyncReportRequest { + GatewaySyncReportRequest { + trace_id: "t".into(), + report_kind: "openai_chat_sync_success".into(), + report_context: None, + status_code: status, + headers: BTreeMap::new(), + body_json: None, + client_body_json: None, + body_base64: None, + telemetry: None, + } + } + + #[test] + fn analytics_failure_preserves_upstream_auth_and_throttling_as_service_failures() { + for status in [401, 429] { + let context = + json!({"error_flow": {"source": "upstream_response", "status_code": status}}); + let result = sync_analytics_context(Some(&context), &sync_payload(status)).unwrap(); + assert_eq!(result["analytics_failure"]["origin"], "upstream"); + assert_eq!(result["analytics_failure"]["schema_version"], 1); + let unknown = sync_analytics_context(Some(&json!({})), &sync_payload(status)).unwrap(); + assert!(unknown.get("analytics_failure").is_none()); + } + } + + #[test] + fn analytics_failure_successful_retry_clears_previous_observations() { + let context = json!({"request_id": "keep", "analytics_failure": {"origin": "upstream"}, "error_flow": {"source": "upstream_response"}, "transport_error": true}); + let result = sync_analytics_context(Some(&context), &sync_payload(200)).unwrap(); + assert_eq!(result["request_id"], "keep"); + assert!(result.get("analytics_failure").is_none()); + assert!(result.get("transport_error").is_none()); + } + + #[test] + fn analytics_failure_transport_uses_observation_and_not_public_error_text() { + let context = + json!({"transport_error": true, "error_flow": {"source": "upstream_response"}}); + let result = sync_analytics_context(Some(&context), &sync_payload(502)).unwrap(); + assert_eq!(result["analytics_failure"]["origin"], "transport"); + assert_eq!( + result["analytics_failure"]["reason"], + "upstream_transport_error" + ); + } + + #[test] + fn analytics_failure_gateway_admission_is_not_client_quota() { + let error = crate::GatewayError::AdmissionTimeout { + trace_id: "t".into(), + gate: "upstream", + queue_budget_ms: 20, + }; + let result = gateway_error_analytics_context(None, &error).unwrap(); + assert_eq!(result["analytics_failure"]["origin"], "gateway"); + assert_eq!(result["analytics_failure"]["stage"], "admission"); + } + + #[test] + fn analytics_failure_structured_error_has_priority_over_http_diagnostic() { + let context = json!({"error_flow": {"source": "upstream_response", "status_code": 502}}); + let error = aether_contracts::ExecutionError { + kind: aether_contracts::ExecutionErrorKind::Internal, + phase: aether_contracts::ExecutionPhase::Decode, + message: "secret diagnostic".into(), + upstream_status: None, + retryable: false, + failover_recommended: false, + }; + let context = execution_error_analytics_context(Some(&context), &error).unwrap(); + let result = sync_analytics_context(Some(&context), &sync_payload(502)).unwrap(); + assert_eq!(result["analytics_failure"]["origin"], "gateway"); + assert_eq!(result["analytics_failure"]["stage"], "decode"); + assert!(!result.to_string().contains("secret diagnostic")); + } + + #[test] + fn analytics_failure_downstream_disconnect_is_distinct_from_unclassified_cancellation() { + let payload = GatewayStreamReportRequest { + trace_id: "t".into(), + report_kind: "openai_chat_stream_error".into(), + report_context: None, + status_code: 499, + headers: BTreeMap::new(), + provider_body_base64: None, + provider_body_state: None, + client_body_base64: None, + client_body_state: None, + terminal_summary: None, + telemetry: None, + }; + let result = stream_analytics_context(None, &payload, true).unwrap(); + assert_eq!(result["analytics_failure"]["origin"], "client"); + assert_eq!( + result["analytics_failure"]["reason"], + "downstream_disconnect" + ); + let unknown = sync_analytics_context(None, &sync_payload(499)).unwrap(); + assert!(unknown.get("analytics_failure").is_none()); + } +} diff --git a/apps/aether-gateway/src/usage/reporting/mod.rs b/apps/aether-gateway/src/usage/reporting/mod.rs index 9d9db708a..5d23d8b8b 100644 --- a/apps/aether-gateway/src/usage/reporting/mod.rs +++ b/apps/aether-gateway/src/usage/reporting/mod.rs @@ -13,6 +13,7 @@ use crate::task_runtime::{spawn_fire_and_forget, TASK_KEY_USAGE_SYNC_REPORT}; use crate::{AppState, GatewayError}; mod context; +pub(crate) mod failure; pub(crate) use context::{ attach_internal_gateway_report_capability, resolve_bound_internal_gateway_report_context, }; @@ -677,6 +678,7 @@ mod tests { ), ("upstream_response".to_string(), json!({"id": "resp-123"})), ("error_flow".to_string(), json!({"stage": "upstream"})), + ("analytics_failure".to_string(), json!({"origin": "upstream", "stage": "response", "reason": "upstream_response_error", "schema_version": 1})), ( "client_response_headers".to_string(), json!({"content-type": "application/json"}), @@ -860,6 +862,27 @@ mod tests { assert!(resolved.is_none(), "cross-operation use must be rejected"); } + for (field, value) in [ + ( + "analytics_attribution", + json!({"is_standalone": false, "actor_user_id": "forged-employee"}), + ), + ("analytics_measurement", json!({"source": "reported"})), + ("usage_token_source", json!("estimated")), + ] { + let mut forged = minted.clone(); + forged[field] = value; + let resolved = resolve_bound_internal_gateway_report_context( + &state, + "trace-capability-fields-123", + "openai_video_create_sync_finalize", + Some(&forged), + ) + .await + .expect("capability lookup should succeed"); + assert!(resolved.is_none(), "unbound {field} must be rejected"); + } + let valid = resolve_bound_internal_gateway_report_context( &state, "trace-capability-fields-123", diff --git a/crates/aether-admin/Cargo.toml b/crates/aether-admin/Cargo.toml index c6d95e611..95f80fc8b 100644 --- a/crates/aether-admin/Cargo.toml +++ b/crates/aether-admin/Cargo.toml @@ -17,6 +17,7 @@ aether-provider-transport.workspace = true axum.workspace = true base64.workspace = true chrono.workspace = true +chrono-tz.workspace = true http.workspace = true reqwest.workspace = true regex.workspace = true diff --git a/crates/aether-admin/src/observability/analytics/csv.rs b/crates/aether-admin/src/observability/analytics/csv.rs new file mode 100644 index 000000000..840805671 --- /dev/null +++ b/crates/aether-admin/src/observability/analytics/csv.rs @@ -0,0 +1,201 @@ +use super::{envelope, page_value, OverviewRequest, OVERVIEW_EXPORT_LIMIT}; +use aether_data_contracts::repository::usage::{StoredUsageAnalytics, UsageAnalyticsView}; +use serde_json::Value; + +pub fn export_csv( + request: &OverviewRequest, + snapshot: &StoredUsageAnalytics, +) -> Result { + if snapshot.total > u64::from(OVERVIEW_EXPORT_LIMIT) { + return Err(format!( + "export exceeds {OVERVIEW_EXPORT_LIMIT} rows; narrow the report range or filters" + )); + } + let page = page_value(request, snapshot); + let items = page["items"].as_array().ok_or("invalid export data")?; + if items.len() as u64 != snapshot.total { + return Err("the complete export could not be read from one snapshot".into()); + } + let columns: &[&str] = match request.query.view { + UsageAnalyticsView::Users => &[ + "user_id", + "username", + "email", + "is_active", + "last_used_at", + "active_days", + "request_count", + "successful_request_count", + "failed_request_count", + "total_tokens", + "billable_amount.value", + "billable_amount.status", + "quota_covered_amount.value", + "wallet_consumed_amount.value", + "wallet_debit_amount.value", + "finance.wallet_balance.value", + "finance.wallet_balance.status", + "finance.recharge_balance.value", + "finance.gift_balance.value", + "finance.recharge_amount.value", + "finance.recharge_count", + "finance.plan_purchase_amount.value", + "finance.plan_purchase_count", + "finance.gift_credit_amount.value", + "finance.gift_credit_count", + "finance.balance_time_basis", + "finance.payment_time_basis", + ], + UsageAnalyticsView::Consumption => &[ + "id", + "request_id", + "started_at", + "user_id", + "credential_owner_id", + "model", + "provider", + "status", + "settlement_status", + "attribution_kind", + "attribution_source", + "rated_amount.value", + "billable_amount.value", + "quota_covered_amount.value", + "wallet_consumed_amount.value", + "wallet_debit_amount.value", + ], + UsageAnalyticsView::Breakdown => &[ + "id", + "label", + "request_count", + "successful_request_count", + "failed_request_count", + "total_tokens", + "rated_amount.value", + "billable_amount.value", + "billable_amount.status", + "quota_covered_amount.value", + "wallet_consumed_amount.value", + "wallet_debit_amount.value", + ], + _ => return Err("this report does not support CSV".into()), + }; + let mut columns = columns.to_vec(); + columns.extend([ + "report.range.from", + "report.range.to", + "report.range.timezone", + "report.scope.kind", + "report.metric_version", + "report.read_revision", + "report.coverage.status", + "report.coverage.unrecoverable_bucket_count", + "billable_amount.currency", + "rated_amount.status", + "quota_covered_amount.status", + "wallet_consumed_amount.status", + "wallet_debit_amount.status", + ]); + let metadata = envelope(request, snapshot, Value::Null)["meta"].clone(); + let mut output = String::from("\u{feff}"); + output.push_str(&columns.join(",")); + output.push_str("\r\n"); + for row in items { + let mut row = row.clone(); + row["report"] = metadata.clone(); + for (index, column) in columns.iter().enumerate() { + if index > 0 { + output.push(','); + } + let value = column.split('.').fold(&row, |value, field| &value[field]); + let text = match value { + Value::Null => String::new(), + Value::String(value) => value.clone(), + value => value.to_string(), + }; + output.push_str(&escape(&text)); + } + output.push_str("\r\n"); + } + Ok(output) +} + +fn escape(value: &str) -> String { + let formula = value.trim_start().starts_with(['=', '+', '-', '@']) + || value.starts_with(['\t', '\r', '\n']); + format!( + "\"{}{}\"", + if formula { "'" } else { "" }, + value.replace('"', "\"\"") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use aether_data_contracts::repository::usage::{ + UsageAnalyticsQuery, UsageAnalyticsUser, UsageAnalyticsUserFinance, + }; + + #[test] + fn quotes_csv_and_prevents_user_fields_from_becoming_formulas() { + assert_eq!(escape("=SUM(1,2)"), "\"'=SUM(1,2)\""); + assert_eq!(escape("a\"b\nc"), "\"a\"\"b\nc\""); + assert_eq!(escape("12.34567890"), "\"12.34567890\""); + } + + #[test] + fn user_export_keeps_consumption_credits_and_current_balances_separate() { + let request = OverviewRequest { + query: UsageAnalyticsQuery { + view: UsageAnalyticsView::Users, + ..Default::default() + }, + csv: true, + amount_basis: "billable".into(), + }; + let snapshot = StoredUsageAnalytics { + total: 1, + users: vec![UsageAnalyticsUser { + user_id: "member".into(), + username: "Alice".into(), + email: None, + is_active: true, + last_used_at: None, + active_days: 1, + metrics: aether_data_contracts::repository::usage::UsageAnalyticsMetrics { + billable_amount: Some("3.00000000".into()), + ..Default::default() + }, + finance: Some(UsageAnalyticsUserFinance { + wallet_balance: Some("12.00000000".into()), + recharge_amount: Some("100.00000000".into()), + recharge_count: 1, + plan_purchase_amount: Some("25.00000000".into()), + plan_purchase_count: 1, + ..Default::default() + }), + }], + ..Default::default() + }; + let csv = export_csv(&request, &snapshot).unwrap(); + let mut lines = csv.trim_start_matches('\u{feff}').lines(); + let headers = lines.next().unwrap().split(',').collect::>(); + let cells = lines.next().unwrap().split(',').collect::>(); + let value = |column| { + cells[headers + .iter() + .position(|candidate| *candidate == column) + .unwrap()] + }; + assert_eq!(value("billable_amount.value"), "\"3.00000000\""); + assert_eq!(value("finance.wallet_balance.value"), "\"12.00000000\""); + assert_eq!(value("finance.recharge_amount.value"), "\"100.00000000\""); + assert_eq!( + value("finance.plan_purchase_amount.value"), + "\"25.00000000\"" + ); + assert_eq!(value("finance.balance_time_basis"), "\"current\""); + assert_eq!(value("finance.payment_time_basis"), "\"credited_at\""); + } +} diff --git a/crates/aether-admin/src/observability/analytics/dashboard.rs b/crates/aether-admin/src/observability/analytics/dashboard.rs new file mode 100644 index 000000000..05587c139 --- /dev/null +++ b/crates/aether-admin/src/observability/analytics/dashboard.rs @@ -0,0 +1,318 @@ +use super::{envelope, metrics_value, parse_overview_query, OverviewRequest}; +use aether_data_contracts::repository::usage::{ + StoredUsageAnalytics, StoredUsageDashboardAnalytics, UsageAnalyticsQuery, UsageAnalyticsRow, + UsageAnalyticsView, UsageDashboardAnalyticsQuery, +}; +use chrono::DateTime; +use serde_json::{json, Value}; + +pub fn parse_dashboard_query(raw: Option<&str>) -> Result { + let mut timezone = None; + for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + if key != "timezone" { + return Err(format!("unsupported dashboard query parameter: {key}")); + } + if timezone.replace(value.into_owned()).is_some() { + return Err("duplicate query parameters are not supported".into()); + } + } + let query = UsageDashboardAnalyticsQuery { + timezone: timezone.unwrap_or_else(|| "UTC".into()), + }; + query.validate().map_err(|err| err.to_string())?; + Ok(query) +} + +pub fn parse_dashboard_charts_query(raw: Option<&str>) -> Result { + for (key, _) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + if !matches!(key.as_ref(), "from" | "to" | "timezone" | "granularity") { + return Err(format!( + "unsupported dashboard charts query parameter: {key}" + )); + } + } + let mut request = parse_overview_query(raw, UsageAnalyticsView::DashboardCharts)?; + request.query.limit = OVERVIEW_CHART_LIMIT; + Ok(request) +} + +const OVERVIEW_CHART_LIMIT: u32 = 10_000; + +pub fn dashboard_charts_value(snapshot: &StoredUsageAnalytics) -> Value { + let rows = |items: &[UsageAnalyticsRow]| { + items + .iter() + .map(|row| { + let mut value = metrics_value(&row.metrics); + if snapshot.unrecoverable_bucket_count > 0 { + mark_incomplete_amounts(&mut value); + } + value["id"] = json!(row.id); + value["label"] = json!(row.label); + value["bucket_start"] = json!(row.bucket_start); + value + }) + .collect::>() + }; + let mut summary = metrics_value(&snapshot.summary); + if snapshot.unrecoverable_bucket_count > 0 { + mark_incomplete_amounts(&mut summary); + } + json!({ + "summary": summary, + "series": rows(&snapshot.rows), + "models": rows(&snapshot.model_rows), + "providers": rows(&snapshot.provider_rows), + }) +} + +pub fn dashboard_value( + query: &UsageDashboardAnalyticsQuery, + snapshot: &StoredUsageDashboardAnalytics, +) -> Result { + let today = summary_value(query, &snapshot.today, &snapshot.today_from, &snapshot.to)?; + let mut total = summary_value( + query, + &snapshot.total, + snapshot.total_from.as_deref().unwrap_or(&snapshot.to), + &snapshot.to, + )?; + total["meta"]["range"]["period"] = json!("all_time"); + total["meta"]["range"]["available_from"] = json!(snapshot.total_from); + // Lifetime cards intentionally omit expensive historical diagnostics. Do not expose + // their uncomputed defaults as measured zeroes. + let total_fields = [ + "request_count", + "total_tokens", + "billable_amount", + "enabled_users", + ]; + total["data"] + .as_object_mut() + .expect("metrics are an object") + .retain(|key, _| total_fields.contains(&key.as_str())); + total["meta"]["available_metrics"] = json!(total_fields); + let total_coverage = total["meta"]["coverage"] + .as_object_mut() + .expect("coverage is an object"); + total_coverage.remove("attribution_available_count"); + total_coverage.remove("classified_failure_count"); + if snapshot.history_complete == Some(false) { + total["meta"]["coverage"]["status"] = json!("partial"); + mark_incomplete_amounts(&mut total["data"]); + } + Ok(json!({"today": today, "total": total, "history_complete": snapshot.history_complete})) +} + +fn summary_value( + query: &UsageDashboardAnalyticsQuery, + snapshot: &StoredUsageAnalytics, + from: &str, + to: &str, +) -> Result { + let timestamp = |value: &str| { + let parsed = DateTime::parse_from_rfc3339(value) + .map_err(|_| "invalid dashboard snapshot timestamp".to_string())?; + u64::try_from(parsed.timestamp_millis()) + .map_err(|_| "invalid dashboard snapshot timestamp".to_string()) + }; + let request = OverviewRequest { + query: UsageAnalyticsQuery { + from_unix_ms: timestamp(from)?, + to_unix_ms: timestamp(to)?, + timezone: query.timezone.clone(), + ..Default::default() + }, + csv: false, + amount_basis: "billable".into(), + }; + let mut data = metrics_value(&snapshot.summary); + if snapshot.unrecoverable_bucket_count > 0 { + mark_incomplete_amounts(&mut data); + } + Ok(envelope(&request, snapshot, data)) +} + +fn mark_incomplete_amounts(data: &mut Value) { + for key in [ + "rated_amount", + "billable_amount", + "quota_covered_amount", + "wallet_consumed_amount", + "wallet_debit_amount", + "wallet_recharge_debit_amount", + "wallet_gift_debit_amount", + "wallet_overdraft_amount", + ] { + if data[key]["status"] == "known" { + data[key]["status"] = json!("known_subtotal"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dashboard_accepts_only_one_valid_timezone() { + assert_eq!(parse_dashboard_query(None).unwrap().timezone, "UTC"); + assert_eq!( + parse_dashboard_query(Some("timezone=Asia%2FShanghai")) + .unwrap() + .timezone, + "Asia/Shanghai" + ); + for raw in [ + "timezone=invalid", + "timezone=UTC&timezone=UTC", + "timezone=UTC&from=2026-01-01T00:00:00Z", + "user_id=employee-1", + "model=test-model", + ] { + assert!(parse_dashboard_query(Some(raw)).is_err(), "{raw}"); + } + } + + #[test] + fn dashboard_charts_keeps_a_bounded_installation_range() { + let range = + "from=2026-09-01T00:00:00Z&to=2026-09-08T00:00:00Z&timezone=UTC&granularity=day"; + let request = parse_dashboard_charts_query(Some(range)).unwrap(); + assert_eq!(request.query.view, UsageAnalyticsView::DashboardCharts); + assert_eq!(request.query.limit, 10_000); + for suffix in [ + "&user_id=alice", + "&model=test", + "&limit=1", + "&format=csv", + "&timezone=UTC", + ] { + assert!(parse_dashboard_charts_query(Some(&format!("{range}{suffix}"))).is_err()); + } + } + + #[test] + fn dashboard_marks_known_missing_history_as_partial() { + let mut snapshot = StoredUsageDashboardAnalytics { + today_from: "2026-09-11T00:00:00Z".into(), + total_from: Some("2024-01-01T00:00:00Z".into()), + to: "2026-09-11T12:00:00Z".into(), + history_complete: Some(false), + ..Default::default() + }; + snapshot.total.summary.billable_amount = Some("3.00000000".into()); + let value = dashboard_value(&parse_dashboard_query(None).unwrap(), &snapshot).unwrap(); + assert_eq!(value["total"]["meta"]["range"]["period"], "all_time"); + assert_eq!(value["total"]["meta"]["coverage"]["status"], "partial"); + assert_eq!( + value["total"]["data"]["billable_amount"]["status"], + "known_subtotal" + ); + assert_eq!(value["today"]["meta"]["coverage"]["status"], "complete"); + assert_eq!(value["history_complete"], false); + } + + #[test] + fn dashboard_today_preserves_known_deleted_usage_coverage() { + let mut snapshot = StoredUsageDashboardAnalytics { + today_from: "2026-09-11T00:00:00Z".into(), + to: "2026-09-11T12:00:00Z".into(), + ..Default::default() + }; + snapshot.today.unrecoverable_bucket_count = 1; + snapshot.today.summary.billable_amount = Some("2.00000000".into()); + let value = dashboard_value(&parse_dashboard_query(None).unwrap(), &snapshot).unwrap(); + assert_eq!(value["today"]["meta"]["coverage"]["status"], "partial"); + assert_eq!( + value["today"]["data"]["billable_amount"]["value"], + "2.00000000" + ); + assert_eq!( + value["today"]["data"]["billable_amount"]["status"], + "known_subtotal" + ); + } + + #[test] + fn dashboard_total_exposes_only_computed_cards_and_preserves_coverage() { + let mut snapshot = StoredUsageDashboardAnalytics { + today_from: "2026-09-11T00:00:00Z".into(), + total_from: Some("2024-01-01T00:00:00Z".into()), + to: "2026-09-11T12:00:00Z".into(), + ..Default::default() + }; + snapshot.today.summary.successful_request_count = 2; + snapshot.today.summary.latency_p95_ms = Some(1200.0); + snapshot.total.summary = aether_data_contracts::repository::usage::UsageAnalyticsMetrics { + request_count: 3, + total_tokens: 120, + usage_available_count: 2, + pricing_available_count: 2, + settled_count: 2, + allocation_available_count: 1, + enabled_users: 4, + billable_amount: Some("1.25000000".into()), + ..Default::default() + }; + let value = dashboard_value(&parse_dashboard_query(None).unwrap(), &snapshot).unwrap(); + assert_eq!( + value["total"]["data"], + json!({ + "request_count": 3, + "total_tokens": 120, + "enabled_users": 4, + "billable_amount": { + "value": "1.25000000", "currency": "USD", "basis": "billable", "status": "known_subtotal" + } + }) + ); + assert_eq!(value["total"]["meta"]["coverage"]["status"], "partial"); + assert_eq!( + value["total"]["meta"]["coverage"]["allocation_available_count"], + 1 + ); + assert_eq!( + value["total"]["meta"]["coverage"]["usage_available_count"], + 2 + ); + assert!(value["total"]["meta"]["coverage"] + .get("attribution_available_count") + .is_none()); + assert!(value["total"]["meta"]["coverage"] + .get("classified_failure_count") + .is_none()); + assert_eq!(value["today"]["data"]["successful_request_count"], 2); + assert_eq!(value["today"]["data"]["latency_ms"]["p95"], 1200.0); + } + + #[test] + fn dashboard_charts_marks_all_amounts_in_a_window_with_lost_history() { + let mut snapshot = StoredUsageAnalytics { + unrecoverable_bucket_count: 1, + ..Default::default() + }; + snapshot.summary.billable_amount = Some("2.00000000".into()); + let row = UsageAnalyticsRow { + id: Some("model-1".into()), + label: None, + bucket_start: Some("2026-09-11T00:00:00Z".into()), + metrics: snapshot.summary.clone(), + }; + snapshot.rows.push(row.clone()); + snapshot.model_rows.push(row.clone()); + snapshot.provider_rows.push(row); + let data = dashboard_charts_value(&snapshot); + assert_eq!( + data["summary"]["billable_amount"]["status"], + "known_subtotal" + ); + for group in ["series", "models", "providers"] { + assert_eq!(data[group][0]["billable_amount"]["value"], "2.00000000"); + assert_eq!( + data[group][0]["billable_amount"]["status"], + "known_subtotal" + ); + } + } +} diff --git a/crates/aether-admin/src/observability/analytics/dashboard_summary.rs b/crates/aether-admin/src/observability/analytics/dashboard_summary.rs new file mode 100644 index 000000000..daa4b889b --- /dev/null +++ b/crates/aether-admin/src/observability/analytics/dashboard_summary.rs @@ -0,0 +1,123 @@ +use super::amount; +use aether_data_contracts::repository::usage::{DashboardSummaryMetrics, StoredDashboardSummary}; +use serde_json::{json, Value}; + +/// The homepage reads a durable aggregate, not the full historical report. +pub fn dashboard_summary_value(snapshot: &StoredDashboardSummary) -> Value { + let total = metrics_value(&snapshot.total); + json!({ + "stats_since": snapshot.stats_since, + "generated_at": snapshot.generated_at, + "timezone": snapshot.timezone, + "today_from": snapshot.today_from, + "window_seconds": snapshot.window_seconds, + "today": metrics_value(&snapshot.today), + "total": { + "request_count": total["request_count"], + "total_tokens": total["total_tokens"], + "cache_read_tokens": total["cache_read_tokens"], + "cache_input_tokens": total["cache_input_tokens"], + "billable_amount": total["billable_amount"], + }, + "users": snapshot.users, + "active_days": snapshot.active_days, + "consecutive_active_days": snapshot.consecutive_active_days, + "activity_days": snapshot.activity_days, + }) +} + +fn metrics_value(metrics: &DashboardSummaryMetrics) -> Value { + let empty = metrics.request_count == 0; + let tokens_available = empty || metrics.usage_available_count > 0; + let billable = if empty { + Some("0.00000000".to_string()) + } else { + metrics.billable_amount.clone() + }; + let average = |sum: f64, samples: u64| { + (samples > 0 && sum.is_finite() && sum >= 0.0).then(|| sum / samples as f64) + }; + json!({ + "request_count": metrics.request_count, + "input_tokens": tokens_available.then_some(metrics.input_tokens), + "output_tokens": tokens_available.then_some(metrics.output_tokens), + "total_tokens": tokens_available.then_some(metrics.total_tokens), + "billable_amount": amount(&billable, "billable", metrics.pricing_available_count == metrics.request_count), + "active_users": metrics.active_users, + "cache_read_tokens": tokens_available.then_some(metrics.cache_read_tokens), + "cache_creation_tokens": tokens_available.then_some(metrics.cache_creation_tokens), + "cache_input_tokens": tokens_available.then_some(metrics.cache_input_tokens), + "avg_first_byte_ms": average(metrics.first_byte_sum_ms, metrics.first_byte_sample_count), + "avg_response_ms": average(metrics.response_sum_ms, metrics.response_sample_count), + "stream_requests": metrics.stream_requests, + "standard_requests": metrics.standard_requests, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_summary_preserves_unknown_cost_and_weighted_response_samples() { + let mut snapshot = StoredDashboardSummary { + stats_since: "2026-09-19T00:00:00Z".into(), + active_days: 90, + consecutive_active_days: 12, + today: DashboardSummaryMetrics { + request_count: 3, + response_sum_ms: 600.0, + response_sample_count: 2, + first_byte_sum_ms: 80.0, + first_byte_sample_count: 1, + ..Default::default() + }, + total: DashboardSummaryMetrics { + request_count: 3, + usage_available_count: 3, + cache_read_tokens: 120, + cache_input_tokens: 400, + pricing_available_count: 1, + billable_amount: Some("1.23456789".into()), + ..Default::default() + }, + ..Default::default() + }; + let value = dashboard_summary_value(&snapshot); + assert_eq!(value["active_days"], 90); + assert_eq!(value["consecutive_active_days"], 12); + assert_eq!(value["today"]["avg_response_ms"], 300.0); + assert_eq!(value["today"]["avg_first_byte_ms"], 80.0); + assert_eq!(value["today"]["billable_amount"]["status"], "unknown"); + assert!(value["today"]["total_tokens"].is_null()); + assert_eq!( + value["total"]["billable_amount"]["status"], + "known_subtotal" + ); + assert_eq!(value["total"]["billable_amount"]["value"], "1.23456789"); + assert_eq!(value["total"]["cache_read_tokens"], 120); + assert_eq!(value["total"]["cache_input_tokens"], 400); + assert!(value["today"].get("latency_p95_ms").is_none()); + + snapshot.total.usage_available_count = 0; + let unknown = dashboard_summary_value(&snapshot); + assert!(unknown["total"].get("cache_read_tokens").unwrap().is_null()); + assert!(unknown["total"] + .get("cache_input_tokens") + .unwrap() + .is_null()); + } + + #[test] + fn empty_collection_has_zero_cost_but_no_measured_latency() { + let value = dashboard_summary_value(&StoredDashboardSummary::default()); + assert_eq!(value["consecutive_active_days"], 0); + assert_eq!(value["today"]["billable_amount"]["value"], "0.00000000"); + assert_eq!(value["today"]["billable_amount"]["status"], "known"); + assert!(value["today"]["avg_response_ms"].is_null()); + assert!(value["today"]["avg_first_byte_ms"].is_null()); + assert_eq!(value["total"]["request_count"], 0); + assert_eq!(value["total"]["cache_read_tokens"], 0); + assert_eq!(value["total"]["cache_input_tokens"], 0); + } +} diff --git a/crates/aether-admin/src/observability/analytics/mod.rs b/crates/aether-admin/src/observability/analytics/mod.rs new file mode 100644 index 000000000..61fcb5f01 --- /dev/null +++ b/crates/aether-admin/src/observability/analytics/mod.rs @@ -0,0 +1,16 @@ +mod csv; +mod dashboard; +mod dashboard_summary; +mod query; +mod response; + +pub use csv::export_csv; +pub use dashboard::{ + dashboard_charts_value, dashboard_value, parse_dashboard_charts_query, parse_dashboard_query, +}; +pub use dashboard_summary::dashboard_summary_value; +pub use query::{parse_overview_query, OverviewRequest, OVERVIEW_EXPORT_LIMIT}; +pub use response::{ + amount, consumption_value, costs_value, envelope, metrics_value, page_value, performance_value, + user_finance_value, user_payments_value, +}; diff --git a/crates/aether-admin/src/observability/analytics/query.rs b/crates/aether-admin/src/observability/analytics/query.rs new file mode 100644 index 000000000..7fc9fb103 --- /dev/null +++ b/crates/aether-admin/src/observability/analytics/query.rs @@ -0,0 +1,318 @@ +use aether_data_contracts::repository::usage::{ + UsageAnalyticsGranularity, UsageAnalyticsGroupBy, UsageAnalyticsQuery, UsageAnalyticsSort, + UsageAnalyticsView, +}; +use chrono::{DateTime, Utc}; +use std::collections::BTreeMap; + +pub const OVERVIEW_EXPORT_LIMIT: u32 = 10_000; + +#[derive(Debug)] +pub struct OverviewRequest { + pub query: UsageAnalyticsQuery, + pub csv: bool, + pub amount_basis: String, +} + +pub fn parse_overview_query( + raw: Option<&str>, + view: UsageAnalyticsView, +) -> Result { + let mut params = BTreeMap::new(); + for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err("duplicate query parameters are not supported".into()); + } + } + let from = timestamp(&mut params, "from")?; + let to = timestamp(&mut params, "to")?; + let timezone = params.remove("timezone").unwrap_or_else(|| "UTC".into()); + let csv = match params.remove("format").as_deref() { + None | Some("json") => false, + Some("csv") + if matches!( + view, + UsageAnalyticsView::Users + | UsageAnalyticsView::Consumption + | UsageAnalyticsView::Breakdown + ) => + { + true + } + _ => return Err("format is not supported for this report".into()), + }; + let limit = number(&mut params, "limit", 25_u32)?; + let offset = number(&mut params, "offset", 0_u64)?; + let payment_limit = params + .remove("payment_limit") + .map(|value| { + value + .parse::() + .map_err(|_| "invalid payment_limit".to_string()) + }) + .transpose()?; + let payment_offset = params + .remove("payment_offset") + .map(|value| { + value + .parse::() + .map_err(|_| "invalid payment_offset".to_string()) + }) + .transpose()?; + if limit == 0 || limit > 100 { + return Err("limit must be between 1 and 100".into()); + } + if csv && offset != 0 { + return Err("CSV exports apply to the complete filter; offset must be zero".into()); + } + let granularity = match params.remove("granularity").as_deref() { + None | Some("day") => UsageAnalyticsGranularity::Day, + Some("hour") => UsageAnalyticsGranularity::Hour, + _ => return Err("granularity must be hour or day".into()), + }; + if granularity == UsageAnalyticsGranularity::Hour && to.saturating_sub(from) > 31 * 86_400_000 { + return Err("hourly reports are limited to 31 days".into()); + } + let group_by = match params.remove("group_by").as_deref() { + None | Some("model") => UsageAnalyticsGroupBy::Model, + Some("provider") => UsageAnalyticsGroupBy::Provider, + Some("api_key") => UsageAnalyticsGroupBy::ApiKey, + Some("attribution") => UsageAnalyticsGroupBy::Attribution, + Some("api_format") => UsageAnalyticsGroupBy::ApiFormat, + Some("request_type") => UsageAnalyticsGroupBy::RequestType, + _ => return Err("unsupported group_by dimension".into()), + }; + let sort = match params.remove("sort").as_deref() { + None | Some("requests") | Some("request_count") => UsageAnalyticsSort::Requests, + Some("billable_amount") => UsageAnalyticsSort::BillableAmount, + Some("total_tokens") => UsageAnalyticsSort::Tokens, + Some("active_days") => UsageAnalyticsSort::ActiveDays, + Some("started_at") => UsageAnalyticsSort::StartedAt, + Some("last_used") | Some("last_used_at") => UsageAnalyticsSort::LastUsed, + Some("username") => UsageAnalyticsSort::Username, + _ => return Err("unsupported sort field".into()), + }; + let descending = match params.remove("order").as_deref() { + None | Some("desc") => true, + Some("asc") => false, + _ => return Err("order must be asc or desc".into()), + }; + let user_is_active = match params.remove("account_status").as_deref() { + None | Some("all") => None, + Some("active") | Some("enabled") => Some(true), + Some("inactive") | Some("disabled") => Some(false), + _ => return Err("unsupported account_status".into()), + }; + let has_usage = match params.remove("usage_status").as_deref() { + None | Some("all") => None, + Some("used") | Some("active") => Some(true), + Some("unused") | Some("inactive") => Some(false), + _ => return Err("unsupported usage_status".into()), + }; + if view != UsageAnalyticsView::Users && (user_is_active.is_some() || has_usage.is_some()) { + return Err( + "account_status and usage_status are only supported by employee reports".into(), + ); + } + let amount_basis = params + .remove("amount_basis") + .unwrap_or_else(|| "billable".into()); + if !matches!( + amount_basis.as_str(), + "rated" | "billable" | "quota_covered" | "wallet_consumed" | "wallet_debit" + ) { + return Err("unsupported amount_basis".into()); + } + let user_id = text(&mut params, "user_id")?; + let attribution_kind = text(&mut params, "attribution_kind")?; + let explicit_owner = text(&mut params, "credential_owner_id")?; + if user_id.is_some() && explicit_owner.is_some() { + return Err("user_id and credential_owner_id cannot be combined".into()); + } + let member_account = attribution_kind.as_deref() == Some("employee"); + let status = match text(&mut params, "status")?.as_deref() { + None => None, + Some("success" | "completed") => Some("completed".into()), + Some(value @ ("failed" | "cancelled" | "pending" | "streaming")) => Some(value.to_string()), + _ => return Err("unsupported request status".into()), + }; + let search = text(&mut params, "search")?; + if search.is_some() && view != UsageAnalyticsView::Users { + return Err("search is only supported by employee reports".into()); + } + let slow_threshold_ms = params + .remove("slow_threshold_ms") + .map(|value| { + value + .parse::() + .map_err(|_| "invalid slow_threshold_ms".to_string()) + }) + .transpose()?; + if slow_threshold_ms.is_some_and(|value| value == 0 || value > 86_400_000) { + return Err("slow_threshold_ms must be between 1 and 86400000".into()); + } + let query = UsageAnalyticsQuery { + from_unix_ms: from, + to_unix_ms: to, + timezone, + view, + group_by, + granularity, + actor_user_id: member_account.then(|| user_id.clone()).flatten(), + credential_owner_id: if member_account { + explicit_owner + } else { + user_id.or(explicit_owner) + }, + attribution_kind, + api_key_id: text(&mut params, "api_key_id")?, + model: text(&mut params, "model")?, + provider_id: text(&mut params, "provider_id")?, + api_format: text(&mut params, "api_format")?, + endpoint_kind: text(&mut params, "endpoint_kind")?, + request_type: text(&mut params, "request_type")?, + status, + is_stream: boolean(&mut params, "is_stream")?, + has_format_conversion: boolean(&mut params, "has_format_conversion")?, + slow_threshold_ms, + search, + user_is_active, + has_usage, + sort, + descending, + limit: if csv { + OVERVIEW_EXPORT_LIMIT + 1 + } else { + limit + }, + offset, + payment_limit, + payment_offset, + }; + if let Some(key) = params.keys().next() { + return Err(format!("unsupported query parameter: {key}")); + } + query.validate().map_err(|err| err.to_string())?; + Ok(OverviewRequest { + query, + csv, + amount_basis, + }) +} + +fn timestamp(params: &mut BTreeMap, key: &str) -> Result { + let value = params + .remove(key) + .ok_or_else(|| format!("{key} is required"))?; + let value = DateTime::parse_from_rfc3339(&value) + .map_err(|_| format!("{key} must be an RFC 3339 timestamp"))? + .with_timezone(&Utc); + u64::try_from(value.timestamp_millis()) + .map_err(|_| format!("{key} must not precede the Unix epoch")) +} + +fn number( + params: &mut BTreeMap, + key: &str, + default: T, +) -> Result { + params + .remove(key) + .map(|value| value.parse().map_err(|_| format!("invalid {key}"))) + .unwrap_or(Ok(default)) +} + +fn boolean(params: &mut BTreeMap, key: &str) -> Result, String> { + match params.remove(key).as_deref() { + None => Ok(None), + Some("true") => Ok(Some(true)), + Some("false") => Ok(Some(false)), + _ => Err(format!("{key} must be true or false")), + } +} + +fn text(params: &mut BTreeMap, key: &str) -> Result, String> { + params + .remove(key) + .map(|value| { + let value = value.trim(); + if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) { + Err(format!("invalid {key}")) + } else { + Ok(value.to_string()) + } + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + + const RANGE: &str = + "from=2026-09-01T23:45:00Z&to=2026-09-02T00:15:00Z&timezone=Asia%2FShanghai"; + + #[test] + fn preserves_precise_cross_midnight_bounds() { + let parsed = parse_overview_query(Some(RANGE), UsageAnalyticsView::Summary).unwrap(); + assert_eq!( + parsed.query.to_unix_ms - parsed.query.from_unix_ms, + 30 * 60 * 1000 + ); + assert_eq!(parsed.query.timezone, "Asia/Shanghai"); + } + + #[test] + fn rejects_unknown_duplicate_and_invalid_ranges() { + for suffix in [ + "&model=x&model=y", + "&invented=1", + "&limit=101", + "&timezone=Europe/Paris", + "&is_stream=perhaps", + ] { + assert!(parse_overview_query( + Some(&format!("{RANGE}{suffix}")), + UsageAnalyticsView::Summary + ) + .is_err()); + } + assert!(parse_overview_query( + Some("from=2026-09-01T00:00:00Z&to=2026-09-01T00:00:00Z"), + UsageAnalyticsView::Summary + ) + .is_err()); + } + + #[test] + fn supports_iana_zone_across_dst_and_full_filter_exports() { + let parsed = parse_overview_query(Some("from=2026-03-08T05:00:00Z&to=2026-03-09T04:00:00Z&timezone=America%2FNew_York&format=csv&search=alice"), UsageAnalyticsView::Users).unwrap(); + assert_eq!( + parsed.query.to_unix_ms - parsed.query.from_unix_ms, + 23 * 3_600_000 + ); + assert_eq!(parsed.query.limit, OVERVIEW_EXPORT_LIMIT + 1); + assert_eq!(parsed.query.search.as_deref(), Some("alice")); + } + + #[test] + fn user_payments_have_independent_bounded_pagination() { + let raw = format!("{RANGE}&payment_limit=10&payment_offset=20&limit=1&offset=0"); + let parsed = parse_overview_query(Some(&raw), UsageAnalyticsView::Users).unwrap(); + assert_eq!(parsed.query.payment_limit, Some(10)); + assert_eq!(parsed.query.payment_offset, Some(20)); + assert_eq!(parsed.query.limit, 1); + assert_eq!(parsed.query.offset, 0); + assert!(parse_overview_query(Some(&raw), UsageAnalyticsView::Summary).is_err()); + for value in ["0", "101", "-1", "garbage"] { + assert!(parse_overview_query( + Some(&format!("{RANGE}&payment_limit={value}")), + UsageAnalyticsView::Users + ) + .is_err()); + } + } +} diff --git a/crates/aether-admin/src/observability/analytics/response.rs b/crates/aether-admin/src/observability/analytics/response.rs new file mode 100644 index 000000000..dc6fbc4fb --- /dev/null +++ b/crates/aether-admin/src/observability/analytics/response.rs @@ -0,0 +1,677 @@ +use super::OverviewRequest; +use aether_data_contracts::repository::usage::{ + StoredUsageAnalytics, UsageAnalyticsConsumption, UsageAnalyticsMetrics, + UsageAnalyticsUserFinance, UsageAnalyticsUserPayments, UsageAnalyticsView, + USAGE_ANALYTICS_VERSION, +}; +use chrono::{DateTime, Datelike, TimeZone, Utc}; +use serde_json::{json, Value}; + +pub fn amount(value: &Option, basis: &str, complete: bool) -> Value { + json!({ + "value": value, "currency": "USD", "basis": basis, + "status": if value.is_none() { "unknown" } else if complete { "known" } else { "known_subtotal" }, + }) +} + +pub fn metrics_value(metrics: &UsageAnalyticsMetrics) -> Value { + let terminal = metrics + .successful_request_count + .saturating_add(metrics.failed_request_count) + .saturating_add(metrics.cancelled_request_count); + let priced = metrics.pricing_available_count == metrics.request_count; + let allocated = metrics.allocation_available_count == metrics.request_count; + let tokens_available = metrics.request_count == 0 || metrics.usage_available_count > 0; + let usage_source = + if metrics.request_count == 0 || metrics.unknown_usage_count == metrics.request_count { + "unknown" + } else if metrics.reported_usage_count == metrics.request_count { + "reported" + } else if metrics.estimated_usage_count == metrics.request_count { + "estimated" + } else { + "mixed" + }; + json!({ + "request_count": metrics.request_count, + "successful_request_count": metrics.successful_request_count, + "failed_request_count": metrics.failed_request_count, + "cancelled_request_count": metrics.cancelled_request_count, + "in_flight_request_count": metrics.in_flight_request_count, + "slow_request_count": metrics.slow_request_count, + "unclassified_failure_count": metrics.failed_request_count.saturating_sub(metrics.classified_failure_count), + "input_tokens": tokens_available.then_some(metrics.input_tokens), + "output_tokens": tokens_available.then_some(metrics.output_tokens), + "total_tokens": tokens_available.then_some(metrics.total_tokens), + "usage_source": usage_source, + "usage_source_counts": { + "reported": metrics.reported_usage_count, + "estimated": metrics.estimated_usage_count, + "mixed": metrics.mixed_usage_count, + "unknown": metrics.unknown_usage_count, + }, + "usage_active_users": metrics.usage_active_users, + "enabled_users": metrics.enabled_users, + "success_rate": { + "value": (terminal > 0).then(|| metrics.successful_request_count as f64 / terminal as f64), + "numerator": metrics.successful_request_count, "denominator": terminal, + }, + "latency_ms": { + "avg": (metrics.latency_sample_count > 0).then(|| metrics.latency_sum_ms / metrics.latency_sample_count as f64), + "p50": metrics.latency_p50_ms, "p95": metrics.latency_p95_ms, "p99": metrics.latency_p99_ms, + "sample_count": metrics.latency_sample_count, + }, + "rated_amount": amount(&metrics.rated_amount, "rated", priced), + "billable_amount": amount(&metrics.billable_amount, "billable", priced), + "quota_covered_amount": amount(&metrics.quota_covered_amount, "quota_covered", allocated), + "wallet_consumed_amount": amount(&metrics.wallet_consumed_amount, "wallet_consumed", allocated), + "wallet_debit_amount": amount(&metrics.wallet_debit_amount, "wallet_debit", allocated), + "wallet_recharge_debit_amount": amount(&metrics.wallet_recharge_debit_amount, "wallet_recharge_debit", allocated), + "wallet_gift_debit_amount": amount(&metrics.wallet_gift_debit_amount, "wallet_gift_debit", allocated), + "wallet_overdraft_amount": amount(&metrics.wallet_overdraft_amount, "wallet_overdraft", allocated), + }) +} + +pub fn envelope(request: &OverviewRequest, snapshot: &StoredUsageAnalytics, data: Value) -> Value { + let query = &request.query; + let metrics = &snapshot.summary; + let complete = snapshot.unrecoverable_bucket_count == 0 + && metrics.usage_available_count == metrics.request_count + && metrics.pricing_available_count == metrics.request_count + && metrics.settled_count == metrics.request_count + && metrics.allocation_available_count == metrics.request_count; + json!({ + "meta": { + "schema_version": 1, "metric_version": USAGE_ANALYTICS_VERSION, + "scope": if let Some(id) = &query.actor_user_id { + json!({"kind": "employee", "user_id": id}) + } else if let Some(id) = &query.credential_owner_id { + json!({"kind": "credential_owner", "user_id": id}) + } else { json!({"kind": "installation"}) }, + "range": { + "from": rfc3339(query.from_unix_ms), "to": rfc3339(query.to_unix_ms), + "timezone": query.timezone, "time_basis": "request_started_at", + }, + "generated_at": snapshot.generated_at, "data_through": snapshot.data_through, + "read_revision": snapshot.read_revision, + "projection": snapshot.coverage, + "amount_basis": request.amount_basis, + "coverage": { + "status": if complete { "complete" } else { "partial" }, + "request_count": metrics.request_count, + "usage_available_count": metrics.usage_available_count, + "pricing_available_count": metrics.pricing_available_count, + "settled_count": metrics.settled_count, + "allocation_available_count": metrics.allocation_available_count, + "attribution_available_count": metrics.trusted_attribution_count, + "classified_failure_count": metrics.classified_failure_count, + "unrecoverable_bucket_count": snapshot.unrecoverable_bucket_count, + }, + }, + "data": data, + }) +} + +pub fn page_value(request: &OverviewRequest, snapshot: &StoredUsageAnalytics) -> Value { + let items: Vec = match request.query.view { + UsageAnalyticsView::Users => snapshot + .users + .iter() + .map(|row| { + let mut value = metrics_value(&row.metrics); + value["user_id"] = json!(row.user_id); + value["username"] = json!(row.username); + value["email"] = json!(row.email); + value["is_active"] = json!(row.is_active); + value["last_used_at"] = json!(row.last_used_at); + value["active_days"] = json!(row.active_days); + value["finance"] = user_finance_value(row.finance.as_ref()); + value + }) + .collect(), + UsageAnalyticsView::Consumption => { + snapshot.consumption.iter().map(consumption_value).collect() + } + _ => snapshot + .rows + .iter() + .map(|row| { + let mut value = metrics_value(&row.metrics); + value["id"] = json!(row.id); + value["label"] = json!(row.label); + value["bucket_start"] = json!(row.bucket_start); + value + }) + .collect(), + }; + let mut page = json!({ "items": items, "total": snapshot.total, "limit": request.query.limit, "offset": request.query.offset }); + if request.query.view == UsageAnalyticsView::Users { + page["summary"] = snapshot + .user_summary + .as_ref() + .map(|summary| { + let mut value = metrics_value(&summary.metrics); + value["user_count"] = json!(summary.user_count); + value["active_user_count"] = json!(summary.active_user_count); + value + }) + .unwrap_or(Value::Null); + page["finance_summary"] = user_finance_value(snapshot.user_finance_summary.as_ref()); + } + page +} + +pub fn user_finance_value(finance: Option<&UsageAnalyticsUserFinance>) -> Value { + let Some(finance) = finance else { + return Value::Null; + }; + json!({ + "wallet_balance": amount(&finance.wallet_balance, "wallet_balance", true), + "recharge_balance": amount(&finance.recharge_balance, "recharge_balance", true), + "gift_balance": amount(&finance.gift_balance, "gift_balance", true), + "recharge_amount": amount(&finance.recharge_amount, "credited_wallet_recharge", true), + "recharge_count": finance.recharge_count, + "plan_purchase_amount": amount(&finance.plan_purchase_amount, "credited_plan_purchase", true), + "plan_purchase_count": finance.plan_purchase_count, + "gift_credit_amount": amount(&finance.gift_credit_amount, "credited_gift_order", true), + "gift_credit_count": finance.gift_credit_count, + "balance_time_basis": "current", + "payment_time_basis": "credited_at", + }) +} + +pub fn user_payments_value(payments: Option<&UsageAnalyticsUserPayments>) -> Value { + let Some(payments) = payments else { + return Value::Null; + }; + let items: Vec = payments + .items + .iter() + .map(|payment| { + json!({ + "id": payment.id, "order_no": payment.order_no, "kind": payment.kind, + "amount": amount(&Some(payment.amount.clone()), "credited_order", true), + "payment_method": payment.payment_method, "credited_at": payment.credited_at, + }) + }) + .collect(); + json!({ "items": items, "total": payments.total, "limit": payments.limit, "offset": payments.offset }) +} + +pub fn consumption_value(row: &UsageAnalyticsConsumption) -> Value { + json!({ + "id": row.id, "request_id": row.request_id, "started_at": row.started_at, + "user_id": row.user_id, "credential_owner_id": row.credential_owner_id, + "model": row.model, "provider": row.provider, "provider_id": row.provider_id, + "api_key_id": row.api_key_id, + "status": row.status, "settlement_status": row.settlement_status, + "attribution_kind": row.attribution_kind, "attribution_source": row.attribution_source, + "rated_amount": amount(&row.rated_amount, "rated", true), + "billable_amount": amount(&row.billable_amount, "billable", true), + "quota_covered_amount": amount(&row.quota_covered_amount, "quota_covered", true), + "wallet_consumed_amount": amount(&row.wallet_consumed_amount, "wallet_consumed", true), + "wallet_debit_amount": amount(&row.wallet_debit_amount, "wallet_debit", true), + }) +} + +pub fn costs_value(request: &OverviewRequest, snapshot: &StoredUsageAnalytics) -> Value { + let metrics = &snapshot.summary; + let cache_complete = metrics.cache_pricing_available_count == metrics.request_count; + let savings = metrics + .cache_estimated_full_cost_amount + .as_deref() + .and_then(decimal_units) + .zip( + metrics + .cache_read_cost_amount + .as_deref() + .and_then(decimal_units), + ) + .and_then(|(full, read)| full.checked_sub(read)) + .map(format_units); + json!({ + "summary": metrics_value(metrics), + "timeseries": snapshot.rows.iter().map(|row| { + let mut value = metrics_value(&row.metrics); + value["bucket_start"] = json!(row.bucket_start); + value + }).collect::>(), + "supplier_estimated_cost": amount(&None, "supplier_estimated", false), + "supplier_verified_cost": amount(&None, "supplier_verified", false), + "cache": { + "read_tokens": (metrics.usage_available_count > 0 || metrics.request_count == 0).then_some(metrics.cache_read_input_tokens), + "creation_tokens": (metrics.usage_available_count > 0 || metrics.request_count == 0).then_some(metrics.cache_creation_input_tokens), + "read_cost": amount(&metrics.cache_read_cost_amount, "cache_read", cache_complete), + "creation_cost": amount(&metrics.cache_creation_cost_amount, "cache_creation", cache_complete), + "estimated_full_cost": estimated_amount(&metrics.cache_estimated_full_cost_amount, "cache_full_price_estimate", cache_complete), + "estimated_savings": estimated_amount(&savings, "cache_read_savings_estimate", cache_complete), + "pricing_available_count": metrics.cache_pricing_available_count, + "request_count": metrics.request_count, + }, + "forecast": forecast(request, snapshot), + }) +} + +fn estimated_amount(value: &Option, basis: &str, complete: bool) -> Value { + let mut result = amount(value, basis, complete); + if value.is_some() { + result["status"] = json!(if complete { + "estimated" + } else { + "estimated_subtotal" + }); + } + result +} + +fn performance_metrics(metrics: &UsageAnalyticsMetrics) -> Value { + let mut value = json!({ + "request_count": metrics.request_count, + "success_count": metrics.successful_request_count, + "error_count": metrics.failed_request_count, + "success_rate": metrics_value(metrics)["success_rate"]["value"], + "output_tokens": metrics.output_tokens, + "avg_output_tps": (metrics.output_tps_sample_count > 0).then(|| metrics.output_tps_sum / metrics.output_tps_sample_count as f64), + "avg_first_byte_time_ms": (metrics.first_byte_sample_count > 0).then(|| metrics.first_byte_sum_ms / metrics.first_byte_sample_count as f64), + "avg_response_time_ms": (metrics.latency_sample_count > 0).then(|| metrics.latency_sum_ms / metrics.latency_sample_count as f64), + "p90_response_time_ms": metrics.latency_p90_ms, + "p99_response_time_ms": metrics.latency_p99_ms, + "p90_first_byte_time_ms": metrics.first_byte_p90_ms, + "p99_first_byte_time_ms": metrics.first_byte_p99_ms, + "tps_sample_count": metrics.output_tps_sample_count, + "response_time_sample_count": metrics.latency_sample_count, + "first_byte_sample_count": metrics.first_byte_sample_count, + "slow_request_count": metrics.slow_request_count, + }); + // The legacy provider chart contract expresses rates as percentages. + value["success_rate"] = value["success_rate"] + .as_f64() + .map(|rate| json!(rate * 100.0)) + .unwrap_or(Value::Null); + value +} + +pub fn performance_value(request: &OverviewRequest, snapshot: &StoredUsageAnalytics) -> Value { + let models = snapshot + .model_rows + .iter() + .map(|row| { + let mut value = performance_metrics(&row.metrics); + value["model"] = json!(row.id); + value + }) + .collect::>(); + let providers = snapshot + .provider_rows + .iter() + .map(|row| { + let mut value = performance_metrics(&row.metrics); + value["provider_id"] = json!(row.id); + value["provider"] = json!(row.label); + value + }) + .collect::>(); + let timeline = snapshot + .provider_timeline_rows + .iter() + .map(|row| { + let mut value = performance_metrics(&row.metrics); + value["provider_id"] = json!(row.id); + value["provider"] = json!(row.label); + value["date"] = json!(row.bucket_start); + value + }) + .collect::>(); + json!({ + "summary": metrics_value(&snapshot.summary), + "timeseries": page_value(request, snapshot)["items"], + "providers": { "summary": performance_metrics(&snapshot.summary), "providers": providers, "timeline": timeline }, + "models": models, + "errors": snapshot.errors, + }) +} + +fn forecast(request: &OverviewRequest, snapshot: &StoredUsageAnalytics) -> Value { + let unavailable = |days| { + json!({ + "amount": amount(&None, "billable_forecast", false), "method": "calendar_month_daily_average", + "status": "insufficient_data", "sample_days": days, "period_end": null, + }) + }; + if snapshot.unrecoverable_bucket_count > 0 { + return unavailable(0); + } + let Ok(zone) = request.query.timezone.parse::() else { + return unavailable(0); + }; + let Some(end) = DateTime::::from_timestamp_millis(request.query.to_unix_ms as i64) else { + return unavailable(0); + }; + // Select the forecast month from the last included instant in this half-open range. + let local_end = (end - chrono::Duration::milliseconds(1)).with_timezone(&zone); + let month = local_end + .date_naive() + .with_day(1) + .expect("first day exists"); + let Some(next_month) = month.checked_add_months(chrono::Months::new(1)) else { + return unavailable(0); + }; + let mut sum = 0_i128; + let mut days = std::collections::BTreeSet::new(); + for row in &snapshot.rows { + let Some(start) = row + .bucket_start + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + else { + continue; + }; + let date = start.with_timezone(&zone).date_naive(); + let Some(next) = date.succ_opt().and_then(|day| { + zone.from_local_datetime(&day.and_hms_opt(0, 0, 0)?) + .earliest() + }) else { + continue; + }; + if date < month + || date >= next_month + || start.timestamp_millis() < request.query.from_unix_ms as i64 + || next.with_timezone(&Utc) > end + || next.with_timezone(&Utc) > Utc::now() + { + continue; + } + if row.metrics.pricing_available_count != row.metrics.request_count + || row.metrics.settled_count != row.metrics.request_count + { + return unavailable(days.len()); + } + let Some(value) = row + .metrics + .billable_amount + .as_deref() + .and_then(decimal_units) + else { + return unavailable(days.len()); + }; + let Some(total) = sum.checked_add(value) else { + return unavailable(days.len()); + }; + sum = total; + days.insert(date); + } + // Missing calendar days are not assumed to be zero-use days. + if days.len() < 7 + || days + .last() + .zip(days.first()) + .is_none_or(|(last, first)| (*last - *first).num_days() + 1 != days.len() as i64) + { + return unavailable(days.len()); + } + let Some(projected) = sum + .checked_mul((next_month - month).num_days() as i128) + .map(|value| value / days.len() as i128) + else { + return unavailable(days.len()); + }; + json!({ + "amount": {"value": format_units(projected), "currency": "USD", "basis": "billable_forecast", "status": "estimated"}, + "method": "calendar_month_daily_average", "status": "estimated", "sample_days": days.len(), + "period_end": zone.from_local_datetime(&next_month.and_hms_opt(0, 0, 0).expect("midnight exists")).earliest().map(|value| value.to_rfc3339()), + }) +} + +fn decimal_units(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 8 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let negative = whole.starts_with('-'); + let whole = whole.parse::().ok()?; + let fractional = if fraction.is_empty() { + 0 + } else { + fraction.parse::().ok()? * 10_i128.pow(8 - fraction.len() as u32) + }; + whole + .checked_mul(100_000_000)? + .checked_add(if negative { -fractional } else { fractional }) +} + +fn format_units(value: i128) -> String { + format!( + "{}{}.{:08}", + if value < 0 { "-" } else { "" }, + value.unsigned_abs() / 100_000_000, + value.unsigned_abs() % 100_000_000 + ) +} + +fn rfc3339(millis: u64) -> Option { + DateTime::::from_timestamp_millis(millis as i64).map(|value| value.to_rfc3339()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_finance_preserves_unknown_sources_and_distinct_payment_bases() { + assert!(user_finance_value(None).is_null()); + assert!(user_payments_value(None).is_null()); + let finance = UsageAnalyticsUserFinance { + wallet_balance: Some("15.00000000".into()), + recharge_amount: Some("100.00000000".into()), + recharge_count: 2, + plan_purchase_amount: Some("25.00000000".into()), + plan_purchase_count: 1, + gift_credit_amount: Some("7.00000000".into()), + gift_credit_count: 1, + ..Default::default() + }; + let value = user_finance_value(Some(&finance)); + assert_eq!(value["wallet_balance"]["value"], "15.00000000"); + assert_eq!(value["recharge_amount"]["value"], "100.00000000"); + assert_eq!( + value["plan_purchase_amount"]["basis"], + "credited_plan_purchase" + ); + assert_eq!(value["gift_credit_amount"]["basis"], "credited_gift_order"); + assert_eq!(value["gift_balance"]["status"], "unknown"); + assert_eq!(value["balance_time_basis"], "current"); + assert_eq!(value["payment_time_basis"], "credited_at"); + } + + #[test] + fn unknown_money_and_zero_denominators_stay_unknown() { + let metrics = UsageAnalyticsMetrics { + request_count: 3, + in_flight_request_count: 3, + ..Default::default() + }; + let value = metrics_value(&metrics); + assert!(value["success_rate"]["value"].is_null()); + assert!(value["latency_ms"]["avg"].is_null()); + assert!(value["total_tokens"].is_null()); + assert!(value["billable_amount"]["value"].is_null()); + assert_eq!(value["billable_amount"]["status"], "unknown"); + } + + #[test] + fn known_subtotal_and_cancelled_consumption_remain_visible() { + let metrics = UsageAnalyticsMetrics { + request_count: 4, + successful_request_count: 2, + cancelled_request_count: 1, + in_flight_request_count: 1, + pricing_available_count: 2, + billable_amount: Some("1.25000000".into()), + ..Default::default() + }; + let value = metrics_value(&metrics); + assert_eq!(value["success_rate"]["denominator"], 3); + assert_eq!(value["billable_amount"]["status"], "known_subtotal"); + assert_eq!(value["billable_amount"]["value"], "1.25000000"); + } + + #[test] + fn overview_model_performance_preserves_unknowns_and_uses_sample_weighted_averages() { + use aether_data_contracts::repository::usage::UsageAnalyticsRow; + let request = super::super::parse_overview_query( + Some("from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z"), + UsageAnalyticsView::Performance, + ) + .unwrap(); + let snapshot = StoredUsageAnalytics { + model_rows: vec![ + UsageAnalyticsRow { + id: Some("requested-model".into()), + label: Some("requested-model".into()), + bucket_start: None, + metrics: UsageAnalyticsMetrics { + request_count: 6, + successful_request_count: 3, + failed_request_count: 1, + cancelled_request_count: 1, + in_flight_request_count: 1, + first_byte_sample_count: 3, + first_byte_sum_ms: 900.0, + output_tps_sample_count: 3, + output_tps_sum: 500.0, + latency_sample_count: 3, + latency_sum_ms: 4900.0, + ..Default::default() + }, + }, + UsageAnalyticsRow { + id: None, + label: None, + bucket_start: None, + metrics: UsageAnalyticsMetrics { + request_count: 1, + in_flight_request_count: 1, + ..Default::default() + }, + }, + ], + ..Default::default() + }; + let value = performance_value(&request, &snapshot); + let rows = value["models"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["model"], "requested-model"); + assert_eq!(rows[0]["success_rate"], 60.0); + assert_eq!(rows[0]["avg_first_byte_time_ms"], 300.0); + assert_eq!(rows[0]["avg_output_tps"], 500.0 / 3.0); + assert_eq!(rows[0]["avg_response_time_ms"], 4900.0 / 3.0); + for field in [ + "model", + "success_rate", + "avg_first_byte_time_ms", + "avg_output_tps", + "avg_response_time_ms", + ] { + assert!(rows[1][field].is_null(), "{field}"); + } + assert!(value["providers"]["providers"].is_array()); + assert!(value["timeseries"].is_array()); + } + + #[test] + fn forecast_arithmetic_keeps_eight_decimal_places_without_floats() { + for value in [ + "12.34567890", + "0.00000001", + "-0.10000000", + "999999999999.99999999", + ] { + assert_eq!(format_units(decimal_units(value).unwrap()), value); + } + } + + #[test] + fn forecast_includes_complete_calendar_month_at_exclusive_local_boundary() { + use aether_data_contracts::repository::usage::UsageAnalyticsRow; + + for timezone in ["UTC", "Asia/Shanghai", "America/New_York"] { + let zone = timezone.parse::().unwrap(); + let from = zone.with_ymd_and_hms(2020, 3, 1, 0, 0, 0).unwrap(); + let to = zone.with_ymd_and_hms(2020, 4, 1, 0, 0, 0).unwrap(); + let request = super::super::parse_overview_query( + Some(&format!( + "from={}&to={}&timezone={timezone}", + from.with_timezone(&Utc) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + to.with_timezone(&Utc) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + )), + UsageAnalyticsView::Timeseries, + ) + .unwrap(); + let snapshot = StoredUsageAnalytics { + rows: (1..=31) + .map(|day| UsageAnalyticsRow { + id: None, + label: None, + bucket_start: Some( + zone.with_ymd_and_hms(2020, 3, day, 0, 0, 0) + .unwrap() + .to_rfc3339(), + ), + metrics: UsageAnalyticsMetrics { + request_count: 1, + pricing_available_count: 1, + settled_count: 1, + billable_amount: Some("1.25000000".into()), + ..Default::default() + }, + }) + .collect(), + ..Default::default() + }; + + let value = costs_value(&request, &snapshot); + assert_eq!(value["forecast"]["sample_days"], 31, "{timezone}"); + assert_eq!(value["forecast"]["status"], "estimated", "{timezone}"); + assert_eq!( + value["forecast"]["amount"]["value"], "38.75000000", + "{timezone}" + ); + assert_eq!( + value["forecast"]["period_end"], + to.to_rfc3339(), + "{timezone}" + ); + } + } + + #[test] + fn cache_savings_use_known_prices_and_preserve_estimate_coverage() { + let request = super::super::parse_overview_query( + Some("from=2026-09-01T00:00:00Z&to=2026-09-02T00:00:00Z"), + UsageAnalyticsView::Timeseries, + ) + .unwrap(); + let mut snapshot = StoredUsageAnalytics::default(); + snapshot.summary.request_count = 2; + snapshot.summary.cache_pricing_available_count = 1; + snapshot.summary.cache_estimated_full_cost_amount = Some("0.10000001".into()); + snapshot.summary.cache_read_cost_amount = Some("0.02000000".into()); + let value = costs_value(&request, &snapshot); + assert_eq!(value["cache"]["estimated_savings"]["value"], "0.08000001"); + assert_eq!( + value["cache"]["estimated_savings"]["status"], + "estimated_subtotal" + ); + snapshot.summary.cache_estimated_full_cost_amount = None; + let value = costs_value(&request, &snapshot); + assert!(value["cache"]["estimated_savings"]["value"].is_null()); + snapshot.unrecoverable_bucket_count = 1; + assert_eq!( + envelope(&request, &snapshot, json!({}))["meta"]["coverage"]["status"], + "partial" + ); + assert_eq!(value["supplier_verified_cost"]["status"], "unknown"); + } +} diff --git a/crates/aether-admin/src/observability/mod.rs b/crates/aether-admin/src/observability/mod.rs index 4c3d21967..77dc3ed29 100644 --- a/crates/aether-admin/src/observability/mod.rs +++ b/crates/aether-admin/src/observability/mod.rs @@ -1,3 +1,4 @@ +pub mod analytics; pub mod monitoring; pub mod stats; pub mod usage; diff --git a/crates/aether-ai/serving/src/report_context.rs b/crates/aether-ai/serving/src/report_context.rs index cc5a7ce35..61612e11f 100644 --- a/crates/aether-ai/serving/src/report_context.rs +++ b/crates/aether-ai/serving/src/report_context.rs @@ -63,6 +63,13 @@ pub fn build_ai_execution_report_context(parts: AiExecutionReportContextParts<'_ "api_key_is_standalone".to_string(), Value::Bool(parts.auth_context.api_key_is_standalone), ); + object.insert( + "analytics_attribution".into(), + serde_json::json!({ + "is_standalone": parts.auth_context.api_key_is_standalone, + "record_kind": "request" + }), + ); object.insert( "username".to_string(), parts @@ -215,10 +222,53 @@ pub fn build_ai_execution_report_context(parts: AiExecutionReportContextParts<'_ ); } - object.extend(parts.extra_fields); + object.extend(parts.extra_fields.into_iter().filter(|(key, _)| { + !matches!( + key.as_str(), + "analytics_attribution" + | "analytics_failure" + | "analytics_measurement" + | "usage_token_source" + ) + })); + if uses_estimated_token_adapter(&object) { + // Bind the adapter's provenance to the planner context. This is a hint, + // not a measurement: usage records materialize it only when tokens exist. + object.insert( + "usage_token_source".into(), + Value::String("estimated".into()), + ); + } Value::Object(object) } +fn uses_estimated_token_adapter(context: &Map) -> bool { + let grok = context + .get("provider_type") + .and_then(Value::as_str) + .is_some_and(|value| value.eq_ignore_ascii_case("grok")) + || context + .get("provider_request_headers") + .and_then(Value::as_object) + .is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("x-aether-grok-runtime") + && value.as_str() == Some("1") + }) + }); + let matches = |key: &str, expected: &str| { + context + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(expected)) + }; + let kiro = context.get("has_envelope").and_then(Value::as_bool) == Some(true) + && matches("envelope_name", "kiro:generateAssistantResponse") + && matches("provider_api_format", "claude:messages") + && !matches("client_envelope_name", "kiro:generateAssistantResponse"); + grok || kiro +} + pub fn provider_stream_event_api_format_for_provider_type( provider_type: &str, ) -> Option<&'static str> { @@ -280,6 +330,13 @@ mod tests { BTreeMap::from([("authorization".to_string(), "Bearer token".to_string())]); let mut extra_fields = Map::new(); extra_fields.insert("extra".to_string(), json!("value")); + extra_fields.insert( + "analytics_attribution".into(), + json!({"is_standalone":true,"actor_user_id":"forged"}), + ); + extra_fields.insert("analytics_failure".into(), json!({"origin":"client"})); + extra_fields.insert("analytics_measurement".into(), json!({"source":"reported"})); + extra_fields.insert("usage_token_source".into(), json!("estimated")); let ranking = SchedulerRankingOutcome { original_index: 2, ranking_index: 1, @@ -290,44 +347,48 @@ mod tests { demoted_by: None, }; - let report = build_ai_execution_report_context(AiExecutionReportContextParts { - auth_context: &auth_context, - request_id: "trace-a", - candidate_id: "candidate-a", - candidate_index: 3, - retry_index: 1, - pool_key_index: Some(0), - model: "gpt-5", - provider_name: "RightCode", - provider_id: "provider-1", - endpoint_id: "endpoint-1", - key_id: "key-1", - key_name: Some("primary"), - model_id: Some("model-1"), - global_model_id: Some("global-1"), - global_model_name: Some("GPT-5"), - provider_api_format: "openai:responses", - client_api_format: "openai:chat", - mapped_model: Some("gpt-5"), - candidate_group_id: Some("group-1"), - ranking: Some(&ranking), - upstream_url: Some("https://example.com/v1/responses"), - header_rules: Some(&json!({"set": []})), - body_rules: None, - provider_request_method: Some(json!("POST")), - provider_request_headers: Some(&provider_headers), - original_headers: &original_headers, - original_request_body: Some(json!({"model": "gpt-5"})), - request_origin: AiRequestOrigin { - client_ip: Some("127.0.0.1".to_string()), - user_agent: Some("test-agent".to_string()), - }, - client_requested_stream: false, - upstream_is_stream: true, - has_envelope: false, - needs_conversion: true, - extra_fields, - }); + let build_report = |provider_headers: &BTreeMap, + extra_fields: Map| { + build_ai_execution_report_context(AiExecutionReportContextParts { + auth_context: &auth_context, + request_id: "trace-a", + candidate_id: "candidate-a", + candidate_index: 3, + retry_index: 1, + pool_key_index: Some(0), + model: "gpt-5", + provider_name: "RightCode", + provider_id: "provider-1", + endpoint_id: "endpoint-1", + key_id: "key-1", + key_name: Some("primary"), + model_id: Some("model-1"), + global_model_id: Some("global-1"), + global_model_name: Some("GPT-5"), + provider_api_format: "openai:responses", + client_api_format: "openai:chat", + mapped_model: Some("gpt-5"), + candidate_group_id: Some("group-1"), + ranking: Some(&ranking), + upstream_url: Some("https://example.com/v1/responses"), + header_rules: Some(&json!({"set": []})), + body_rules: None, + provider_request_method: Some(json!("POST")), + provider_request_headers: Some(provider_headers), + original_headers: &original_headers, + original_request_body: Some(json!({"model": "gpt-5"})), + request_origin: AiRequestOrigin { + client_ip: Some("127.0.0.1".to_string()), + user_agent: Some("test-agent".to_string()), + }, + client_requested_stream: false, + upstream_is_stream: true, + has_envelope: false, + needs_conversion: true, + extra_fields, + }) + }; + let report = build_report(&provider_headers, extra_fields.clone()); assert_eq!(report["user_id"], "user-1"); assert_eq!(report["candidate_index"], 3); @@ -341,6 +402,47 @@ mod tests { "Bearer token" ); assert_eq!(report["extra"], "value"); + assert_eq!( + report["analytics_attribution"], + json!({"is_standalone":false,"record_kind":"request"}) + ); + assert!(report["analytics_attribution"] + .get("actor_user_id") + .is_none()); + assert!(report.get("analytics_failure").is_none()); + assert!(report.get("analytics_measurement").is_none()); + assert!(report.get("usage_token_source").is_none()); + + let grok_headers = BTreeMap::from([("X-Aether-Grok-Runtime".into(), "1".into())]); + assert_eq!( + build_report(&grok_headers, extra_fields.clone())["usage_token_source"], + "estimated" + ); + let mut native = extra_fields.clone(); + native.insert("provider_type".into(), json!("Grok")); + assert_eq!( + build_report(&provider_headers, native)["usage_token_source"], + "estimated" + ); + + let mut kiro = extra_fields; + kiro.insert("has_envelope".into(), json!(true)); + kiro.insert( + "envelope_name".into(), + json!("kiro:generateAssistantResponse"), + ); + kiro.insert("provider_api_format".into(), json!("claude:messages")); + assert_eq!( + build_report(&provider_headers, kiro.clone())["usage_token_source"], + "estimated" + ); + kiro.insert( + "client_envelope_name".into(), + json!("kiro:generateAssistantResponse"), + ); + assert!(build_report(&provider_headers, kiro) + .get("usage_token_source") + .is_none()); } #[test] diff --git a/crates/aether-contracts/src/lib.rs b/crates/aether-contracts/src/lib.rs index a64f0a49f..7d7c4bbef 100644 --- a/crates/aether-contracts/src/lib.rs +++ b/crates/aether-contracts/src/lib.rs @@ -23,5 +23,6 @@ pub use plan::{ }; pub use result::{ExecutionResponseObservation, ExecutionResult, ExecutionTelemetry, ResponseBody}; pub use usage::{ - ExecutionStreamTerminalSummary, StandardizedUsage, USAGE_SERVER_NOW_UNIX_MS_HEADER, + ExecutionStreamTerminalSummary, StandardizedUsage, UsageTokenSource, + USAGE_SERVER_NOW_UNIX_MS_HEADER, }; diff --git a/crates/aether-contracts/src/usage.rs b/crates/aether-contracts/src/usage.rs index 8ca5e730a..ffc3e9773 100644 --- a/crates/aether-contracts/src/usage.rs +++ b/crates/aether-contracts/src/usage.rs @@ -4,8 +4,28 @@ use serde::{Deserialize, Serialize}; pub const USAGE_SERVER_NOW_UNIX_MS_HEADER: &str = "x-aether-server-now-unix-ms"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageTokenSource { + Reported, + Estimated, + Mixed, +} + +impl UsageTokenSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Reported => "reported", + Self::Estimated => "estimated", + Self::Mixed => "mixed", + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct StandardizedUsage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_source: Option, pub input_tokens: i64, pub output_tokens: i64, pub cache_creation_tokens: i64, @@ -28,6 +48,7 @@ impl StandardizedUsage { pub fn get(&self, field_name: &str) -> Option { match field_name { + "token_source" => self.token_source.map(|source| serde_json::json!(source)), "input_tokens" => Some(serde_json::json!(self.input_tokens)), "output_tokens" => Some(serde_json::json!(self.output_tokens)), "cache_creation_tokens" => Some(serde_json::json!(self.cache_creation_tokens)), @@ -49,6 +70,7 @@ impl StandardizedUsage { pub fn set(&mut self, field_name: &str, value: impl Into) { let value = value.into(); match field_name { + "token_source" => self.token_source = serde_json::from_value(value).ok(), "input_tokens" => self.input_tokens = as_i64(&value, 0), "output_tokens" => self.output_tokens = as_i64(&value, 0), "cache_creation_tokens" => self.cache_creation_tokens = as_i64(&value, 0), @@ -159,7 +181,45 @@ fn as_f64(value: &serde_json::Value, default: f64) -> f64 { #[cfg(test)] mod tests { - use super::{ExecutionStreamTerminalSummary, StandardizedUsage}; + use super::{ExecutionStreamTerminalSummary, StandardizedUsage, UsageTokenSource}; + + #[test] + fn token_source_survives_wire_roundtrip_without_affecting_usage_completeness() { + let mut estimated = StandardizedUsage::new(); + estimated.set("token_source", "estimated"); + assert!(!estimated.has_token_signal()); + assert!(estimated.dimensions.is_empty()); + assert_eq!(estimated.token_source, Some(UsageTokenSource::Estimated)); + estimated.output_tokens = 17; + let score = estimated.signal_score(); + let summary = ExecutionStreamTerminalSummary { + standardized_usage: Some(estimated), + ..Default::default() + }; + let encoded = serde_json::to_value(&summary).unwrap(); + assert_eq!(encoded["standardized_usage"]["token_source"], "estimated"); + let decoded: ExecutionStreamTerminalSummary = serde_json::from_value(encoded).unwrap(); + let decoded = decoded.standardized_usage.unwrap(); + assert_eq!(decoded.token_source.unwrap().as_str(), "estimated"); + assert_eq!(decoded.signal_score(), score); + let complete = StandardizedUsage { + input_tokens: 29, + output_tokens: 17, + ..StandardizedUsage::new() + }; + assert_eq!( + StandardizedUsage::choose_more_complete(Some(decoded), Some(complete.clone())), + Some(complete) + ); + let legacy = serde_json::to_value(StandardizedUsage::new()).unwrap(); + assert!(legacy.get("token_source").is_none()); + assert_eq!( + serde_json::from_value::(legacy) + .unwrap() + .token_source, + None + ); + } #[test] fn standardized_usage_prefers_more_complete_candidate() { diff --git a/crates/aether-data/adapters/postgres/Cargo.toml b/crates/aether-data/adapters/postgres/Cargo.toml index e10b57154..945eeae4a 100644 --- a/crates/aether-data/adapters/postgres/Cargo.toml +++ b/crates/aether-data/adapters/postgres/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true description = "PostgreSQL repositories, pools, transactions, and migrations for Aether" [dependencies] +serde.workspace = true aether-ai-formats.workspace = true aether-data-contracts.workspace = true aether-data-query.workspace = true diff --git a/crates/aether-data/adapters/postgres/migrations/20260403000000_baseline.sql b/crates/aether-data/adapters/postgres/migrations/20260403000000_baseline.sql index 04b0d926e..e05a7ddba 100644 --- a/crates/aether-data/adapters/postgres/migrations/20260403000000_baseline.sql +++ b/crates/aether-data/adapters/postgres/migrations/20260403000000_baseline.sql @@ -18,10 +18,7 @@ -- Runs before 20260403000000_baseline.sql so that fresh databases have a -- complete schema by the time baseline (a no-op handoff point) and all -- later ADD COLUMN IF NOT EXISTS migrations execute. -SET statement_timeout = 0; - -SET lock_timeout = 0; - +-- Keep the migration runner's statement and lock deadlines in effect. SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; diff --git a/crates/aether-data/adapters/postgres/migrations/20260911000000_add_overview_analytics.sql b/crates/aether-data/adapters/postgres/migrations/20260911000000_add_overview_analytics.sql new file mode 100644 index 000000000..8315901ec --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260911000000_add_overview_analytics.sql @@ -0,0 +1,278 @@ +-- Final overview schema and account attribution; no historical usage is backfilled. +-- Install transaction-owned dirty events before any later concurrent index build. +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_origin text; +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_stage text; +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_reason text; +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_schema_version integer; +ALTER TABLE public.usage_settlement_snapshots + ADD COLUMN IF NOT EXISTS quota_covered_amount_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_consumed_amount_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_debit_amount_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_recharge_debit_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_gift_debit_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_overdraft_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS allocation_schema_version integer, + ADD COLUMN IF NOT EXISTS allocation_status text; + +CREATE TABLE IF NOT EXISTS public.usage_attribution_snapshots ( + request_id text PRIMARY KEY, + actor_user_id text, + credential_owner_id text, + attribution_kind text NOT NULL DEFAULT 'unknown', + attribution_source text NOT NULL DEFAULT 'unknown', + record_kind text NOT NULL DEFAULT 'request', + parent_request_id text, + schema_version integer NOT NULL DEFAULT 1, + attribution_revision bigint NOT NULL DEFAULT 1, + recorded_at timestamptz NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS ix_usage_attribution_actor_request + ON public.usage_attribution_snapshots(actor_user_id, request_id); +-- This attribution table is new and empty; build both lookup indexes here. +CREATE INDEX IF NOT EXISTS ix_usage_attribution_owner_request + ON public.usage_attribution_snapshots(credential_owner_id, request_id); + +CREATE TABLE IF NOT EXISTS public.stats_bucket_state ( + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamptz NOT NULL, + source_revision bigint NOT NULL DEFAULT 0, + built_revision bigint NOT NULL DEFAULT -1, + coverage_status text NOT NULL DEFAULT 'unbuilt', + built_at timestamptz, + last_error text, + last_failed_at timestamptz, + PRIMARY KEY (projection_version, granularity, bucket_start) +); +CREATE INDEX IF NOT EXISTS ix_stats_bucket_state_dirty + ON public.stats_bucket_state(bucket_start) + WHERE source_revision > built_revision; + +CREATE TABLE IF NOT EXISTS public.stats_overview_hourly ( + projection_version text NOT NULL, + bucket_start timestamptz NOT NULL, + dimensions jsonb NOT NULL, + metrics jsonb NOT NULL, + PRIMARY KEY(projection_version, bucket_start, dimensions) +); +CREATE TABLE IF NOT EXISTS public.stats_overview_daily ( + projection_version text NOT NULL, + bucket_start timestamptz NOT NULL, + dimensions jsonb NOT NULL, + metrics jsonb NOT NULL, + PRIMARY KEY(projection_version, bucket_start, dimensions) +); + +-- These triggers cover old writers, delayed settlement, and maintenance in the fact transaction. +-- Only future fact mutations enqueue work; no historical rows are backfilled. +-- Each writer owns its transaction's keys, so unrelated requests never contend +-- on the current hour/day's stats_bucket_state row. +CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events ( + transaction_id bigint NOT NULL, + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamptz NOT NULL, + unrecoverable boolean NOT NULL DEFAULT false, + PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start) +); +CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket + ON public.stats_overview_dirty_events(projection_version, granularity, bucket_start); + +CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE old_time timestamptz; new_time timestamptz; bucket record; +BEGIN + IF TG_TABLE_NAME = 'usage' THEN + IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF; + IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF; + ELSE + IF TG_OP <> 'INSERT' THEN + SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id; + END IF; + END IF; + FOR bucket IN + SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts + FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g + WHERE t IS NOT NULL ORDER BY g, starts + LOOP + INSERT INTO public.stats_overview_dirty_events + (transaction_id, projection_version, granularity, bucket_start, unrecoverable) + VALUES (txid_current(), 'overview-v2', bucket.g, bucket.starts, + TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE') + ON CONFLICT (transaction_id, projection_version, granularity, bucket_start) + DO UPDATE SET unrecoverable = stats_overview_dirty_events.unrecoverable OR EXCLUDED.unrecoverable; + END LOOP; + RETURN NULL; +END $$; + +DROP TRIGGER IF EXISTS overview_usage_dirty ON public.usage; +CREATE TRIGGER overview_usage_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket(); +DROP TRIGGER IF EXISTS overview_settlement_dirty ON public.usage_settlement_snapshots; +CREATE TRIGGER overview_settlement_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_settlement_snapshots + FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket(); +DROP TRIGGER IF EXISTS overview_attribution_dirty ON public.usage_attribution_snapshots; +CREATE TRIGGER overview_attribution_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_attribution_snapshots + FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket(); + +CREATE OR REPLACE FUNCTION public.overview_capture_usage_identity() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE actor text; owner text; kind text; source text; standalone boolean; +BEGIN + SELECT id INTO owner FROM public.users WHERE id=NEW.user_id AND NOT is_deleted; + SELECT COALESCE(k.is_standalone, + CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean' + THEN (NEW.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END, + CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb->'api_key_is_standalone')='boolean' + THEN (NEW.request_metadata->>'api_key_is_standalone')::boolean END, + CASE WHEN a.attribution_source='user_account' THEN false + WHEN a.attribution_source='standalone_key' THEN true END, + CASE WHEN NEW.api_key_id IS NULL THEN false END) + INTO standalone FROM (SELECT 1) seed + LEFT JOIN public.api_keys k ON k.id=NEW.api_key_id + LEFT JOIN public.usage_attribution_snapshots a ON a.request_id=NEW.request_id; + kind := CASE WHEN owner IS NULL THEN 'unknown' WHEN standalone THEN 'standalone' + WHEN NOT standalone THEN 'employee' ELSE 'unknown' END; + actor := CASE WHEN kind='employee' THEN owner END; + source := CASE kind WHEN 'employee' THEN 'user_account' WHEN 'standalone' THEN 'standalone_key' ELSE 'unknown' END; + INSERT INTO public.usage_attribution_snapshots(request_id, actor_user_id, credential_owner_id, + attribution_kind, attribution_source, record_kind, parent_request_id, attribution_revision) + VALUES (NEW.request_id, actor, owner, kind, source, + COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', 'request'), + NEW.request_metadata #>> '{analytics_attribution,parent_request_id}', 2) + ON CONFLICT (request_id) DO UPDATE SET actor_user_id = EXCLUDED.actor_user_id, + credential_owner_id = EXCLUDED.credential_owner_id, attribution_kind = EXCLUDED.attribution_kind, + attribution_source = EXCLUDED.attribution_source, + record_kind = COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', usage_attribution_snapshots.record_kind), + parent_request_id = COALESCE(EXCLUDED.parent_request_id, usage_attribution_snapshots.parent_request_id), + attribution_revision = EXCLUDED.attribution_revision, recorded_at = NOW() + WHERE usage_attribution_snapshots.attribution_revision <= EXCLUDED.attribution_revision; + RETURN NULL; +END $$; +DROP TRIGGER IF EXISTS overview_usage_identity ON public.usage; +CREATE TRIGGER overview_usage_identity AFTER INSERT OR UPDATE OF user_id, api_key_id, request_metadata ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_capture_usage_identity(); + +CREATE OR REPLACE FUNCTION public.overview_capture_failure() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.status = 'completed' THEN + NEW.failure_origin := NULL; NEW.failure_stage := NULL; NEW.failure_reason := NULL; + NEW.failure_schema_version := NULL; RETURN NEW; + END IF; + IF NEW.request_metadata #>> '{analytics_failure,origin}' IN ('client','gateway','upstream','transport','unknown') THEN + NEW.failure_origin := NEW.request_metadata #>> '{analytics_failure,origin}'; + NEW.failure_stage := NEW.request_metadata #>> '{analytics_failure,stage}'; + NEW.failure_reason := NEW.request_metadata #>> '{analytics_failure,reason}'; + NEW.failure_schema_version := 1; + END IF; + RETURN NEW; +END $$; +DROP TRIGGER IF EXISTS overview_usage_failure ON public.usage; +CREATE TRIGGER overview_usage_failure BEFORE INSERT OR UPDATE OF request_metadata, status ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_capture_failure(); + +CREATE OR REPLACE FUNCTION public.overview_anonymize_user() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' OR NEW.is_deleted THEN + UPDATE public.usage_attribution_snapshots SET actor_user_id = NULL, credential_owner_id = NULL, + attribution_kind = 'unknown', attribution_source = 'unknown', attribution_revision = attribution_revision + 10 + WHERE actor_user_id = OLD.id OR credential_owner_id = OLD.id; + UPDATE public.usage SET request_metadata = (request_metadata::jsonb #- '{analytics_attribution,actor_user_id}')::json + WHERE request_metadata #>> '{analytics_attribution,actor_user_id}' = OLD.id; + DELETE FROM public.stats_overview_hourly WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id; + DELETE FROM public.stats_overview_daily WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id; + END IF; + RETURN NULL; +END $$; +DROP TRIGGER IF EXISTS overview_user_anonymize ON public.users; +CREATE TRIGGER overview_user_anonymize AFTER DELETE OR UPDATE OF is_deleted ON public.users + FOR EACH ROW EXECUTE FUNCTION public.overview_anonymize_user(); + +CREATE OR REPLACE FUNCTION public.overview_delete_attribution() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + DELETE FROM public.usage_attribution_snapshots WHERE request_id = OLD.request_id; + RETURN OLD; +END $$; +DROP TRIGGER IF EXISTS overview_usage_delete_attribution ON public.usage; +CREATE TRIGGER overview_usage_delete_attribution BEFORE DELETE ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_delete_attribution(); + +CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS +SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at, + CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id, + identity.owner_id AS credential_owner_id, + CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone' + WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind, + CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key' + WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source, + COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id, + u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name, + u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion, + u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason, + u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms, + COALESCE(s.billing_status, u.billing_status) AS settlement_status, + COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available, + COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.input_tokens END AS input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.output_tokens END AS output_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.total_tokens END AS total_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.cache_read_input_tokens END AS cache_read_input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount, + s.quota_covered_amount_usd AS quota_covered_amount, + s.wallet_consumed_amount_usd AS wallet_consumed_amount, + s.wallet_debit_amount_usd AS wallet_debit_amount, + s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount, + s.wallet_gift_debit_usd AS wallet_gift_debit_amount, + s.wallet_overdraft_usd AS wallet_overdraft_amount, + s.allocation_status, s.finalized_at AS settled_at, + CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source, + b.upstream_is_stream, + CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed') + THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL + THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL + THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL + THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount +FROM public.usage u +LEFT JOIN public.usage_settlement_snapshots s USING (request_id) +LEFT JOIN public.usage_attribution_snapshots a USING (request_id) +JOIN public.usage_billing_facts b USING (request_id) +LEFT JOIN public.api_keys k ON k.id=u.api_key_id +CROSS JOIN LATERAL ( + SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id + WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id, + COALESCE(k.is_standalone, + CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean' + THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END, + CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean' + THEN (u.request_metadata->>'api_key_is_standalone')::boolean END, + CASE WHEN a.attribution_source='user_account' THEN false + WHEN a.attribution_source='standalone_key' THEN true END, + CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone +) identity; diff --git a/crates/aether-data/adapters/postgres/migrations/20260917000000_use_existing_key_account_attribution.sql b/crates/aether-data/adapters/postgres/migrations/20260917000000_use_existing_key_account_attribution.sql new file mode 100644 index 000000000..91e138f73 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260917000000_use_existing_key_account_attribution.sql @@ -0,0 +1,146 @@ +-- Retain existing migration checksums and historical records; only future writes create v2 buckets. +DROP VIEW IF EXISTS public.usage_analytics_facts_v1; +ALTER TABLE public.api_keys DROP COLUMN IF EXISTS credential_kind; +ALTER TABLE public.usage_attribution_snapshots DROP COLUMN IF EXISTS credential_kind; + +CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE old_time timestamptz; new_time timestamptz; bucket record; +BEGIN + IF TG_TABLE_NAME = 'usage' THEN + IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF; + IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF; + ELSE + IF TG_OP <> 'INSERT' THEN + SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id; + END IF; + END IF; + FOR bucket IN + SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts + FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g + WHERE t IS NOT NULL ORDER BY g, starts + LOOP + INSERT INTO public.stats_bucket_state(projection_version, granularity, bucket_start, source_revision) + VALUES ('overview-v2', bucket.g, bucket.starts, 1) + ON CONFLICT (projection_version, granularity, bucket_start) + DO UPDATE SET source_revision = stats_bucket_state.source_revision + 1; + IF TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE' THEN + UPDATE public.stats_bucket_state SET coverage_status='unrecoverable', + last_error='retained usage facts were deleted' + WHERE projection_version='overview-v2' AND granularity=bucket.g AND bucket_start=bucket.starts; + END IF; + END LOOP; + RETURN NULL; +END $$; + +CREATE OR REPLACE FUNCTION public.overview_capture_usage_identity() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE actor text; owner text; kind text; source text; standalone boolean; +BEGIN + SELECT id INTO owner FROM public.users WHERE id=NEW.user_id AND NOT is_deleted; + SELECT COALESCE(k.is_standalone, + CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean' + THEN (NEW.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END, + CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb->'api_key_is_standalone')='boolean' + THEN (NEW.request_metadata->>'api_key_is_standalone')::boolean END, + CASE WHEN a.attribution_source='user_account' THEN false + WHEN a.attribution_source='standalone_key' THEN true END, + CASE WHEN NEW.api_key_id IS NULL THEN false END) + INTO standalone FROM (SELECT 1) seed + LEFT JOIN public.api_keys k ON k.id=NEW.api_key_id + LEFT JOIN public.usage_attribution_snapshots a ON a.request_id=NEW.request_id; + kind := CASE WHEN owner IS NULL THEN 'unknown' WHEN standalone THEN 'standalone' + WHEN NOT standalone THEN 'employee' ELSE 'unknown' END; + actor := CASE WHEN kind='employee' THEN owner END; + source := CASE kind WHEN 'employee' THEN 'user_account' WHEN 'standalone' THEN 'standalone_key' ELSE 'unknown' END; + INSERT INTO public.usage_attribution_snapshots(request_id, actor_user_id, credential_owner_id, + attribution_kind, attribution_source, record_kind, parent_request_id, attribution_revision) + VALUES (NEW.request_id, actor, owner, kind, source, + COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', 'request'), + NEW.request_metadata #>> '{analytics_attribution,parent_request_id}', 2) + ON CONFLICT (request_id) DO UPDATE SET actor_user_id = EXCLUDED.actor_user_id, + credential_owner_id = EXCLUDED.credential_owner_id, attribution_kind = EXCLUDED.attribution_kind, + attribution_source = EXCLUDED.attribution_source, + record_kind = COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', usage_attribution_snapshots.record_kind), + parent_request_id = COALESCE(EXCLUDED.parent_request_id, usage_attribution_snapshots.parent_request_id), + attribution_revision = EXCLUDED.attribution_revision, recorded_at = NOW() + WHERE usage_attribution_snapshots.attribution_revision <= EXCLUDED.attribution_revision; + RETURN NULL; +END $$; + +CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS +SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at, + CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id, + identity.owner_id AS credential_owner_id, + CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone' + WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind, + CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key' + WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source, + COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id, + u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name, + u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion, + u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason, + u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms, + COALESCE(s.billing_status, u.billing_status) AS settlement_status, + COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available, + COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.input_tokens END AS input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.output_tokens END AS output_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.total_tokens END AS total_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.cache_read_input_tokens END AS cache_read_input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount, + s.quota_covered_amount_usd AS quota_covered_amount, + s.wallet_consumed_amount_usd AS wallet_consumed_amount, + s.wallet_debit_amount_usd AS wallet_debit_amount, + s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount, + s.wallet_gift_debit_usd AS wallet_gift_debit_amount, + s.wallet_overdraft_usd AS wallet_overdraft_amount, + s.allocation_status, s.finalized_at AS settled_at, + CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source, + b.upstream_is_stream, + CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed') + THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL + THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL + THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL + THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount +FROM public.usage u +LEFT JOIN public.usage_settlement_snapshots s USING (request_id) +LEFT JOIN public.usage_attribution_snapshots a USING (request_id) +JOIN public.usage_billing_facts b USING (request_id) +LEFT JOIN public.api_keys k ON k.id=u.api_key_id +CROSS JOIN LATERAL ( + SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id + WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id, + COALESCE(k.is_standalone, + CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean' + THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END, + CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean' + THEN (u.request_metadata->>'api_key_is_standalone')::boolean END, + CASE WHEN a.attribution_source='user_account' THEN false + WHEN a.attribution_source='standalone_key' THEN true END, + CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone +) identity; diff --git a/crates/aether-data/adapters/postgres/migrations/20260917000100_decouple_overview_dirty_writes.sql b/crates/aether-data/adapters/postgres/migrations/20260917000100_decouple_overview_dirty_writes.sql new file mode 100644 index 000000000..f5116ae4a --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260917000100_decouple_overview_dirty_writes.sql @@ -0,0 +1,46 @@ +-- Upgrade databases that already applied the original account-attribution migration. +-- Keep this before concurrent index builds so their failure cannot leave writers +-- contending on shared hourly/daily aggregate rows. Fresh databases are safe too. +-- Only future fact mutations enqueue work; no historical rows are backfilled. +-- Each writer owns its transaction's keys, so unrelated requests never contend +-- on the current hour/day's stats_bucket_state row. +CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events ( + transaction_id bigint NOT NULL, + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamptz NOT NULL, + unrecoverable boolean NOT NULL DEFAULT false, + PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start) +); +CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket + ON public.stats_overview_dirty_events(projection_version, granularity, bucket_start); + +CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE old_time timestamptz; new_time timestamptz; bucket record; +BEGIN + IF TG_TABLE_NAME = 'usage' THEN + IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF; + IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF; + ELSE + IF TG_OP <> 'INSERT' THEN + SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id; + END IF; + END IF; + FOR bucket IN + SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts + FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g + WHERE t IS NOT NULL ORDER BY g, starts + LOOP + INSERT INTO public.stats_overview_dirty_events + (transaction_id, projection_version, granularity, bucket_start, unrecoverable) + VALUES (txid_current(), 'overview-v2', bucket.g, bucket.starts, + TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE') + ON CONFLICT (transaction_id, projection_version, granularity, bucket_start) + DO UPDATE SET unrecoverable = stats_overview_dirty_events.unrecoverable OR EXCLUDED.unrecoverable; + END LOOP; + RETURN NULL; +END $$; diff --git a/crates/aether-data/adapters/postgres/migrations/20260918000000_extend_usage_settlement_dashboard_covering_index.sql b/crates/aether-data/adapters/postgres/migrations/20260918000000_extend_usage_settlement_dashboard_covering_index.sql new file mode 100644 index 000000000..cf52f3adf --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260918000000_extend_usage_settlement_dashboard_covering_index.sql @@ -0,0 +1,24 @@ +-- no-transaction +-- Keep the existing billing cover while building its replacement. Overview +-- totals also read settlement/allocation status; omitting those columns forces +-- a scan of the wide snapshot heap even when every monetary field is covered. +-- The following migration retires the old index only after this build succeeds. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_settlement_dashboard_cover_v2 +ON public.usage_settlement_snapshots (request_id) +INCLUDE ( + billing_input_tokens, + billing_effective_input_tokens, + billing_output_tokens, + billing_cache_creation_tokens, + billing_cache_creation_5m_tokens, + billing_cache_creation_1h_tokens, + billing_cache_read_tokens, + billing_total_input_context, + billing_cache_creation_cost_usd, + billing_cache_read_cost_usd, + billing_total_cost_usd, + billing_actual_total_cost_usd, + input_price_per_1m, + billing_status, + allocation_status +); diff --git a/crates/aether-data/adapters/postgres/migrations/20260918000100_retire_previous_usage_settlement_dashboard_covering_index.sql b/crates/aether-data/adapters/postgres/migrations/20260918000100_retire_previous_usage_settlement_dashboard_covering_index.sql new file mode 100644 index 000000000..8eae55310 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260918000100_retire_previous_usage_settlement_dashboard_covering_index.sql @@ -0,0 +1,4 @@ +-- no-transaction +-- The preceding migration has published the superset index. Do not keep two +-- permanent covering indexes with the same key and almost identical payloads. +DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_settlement_dashboard_cover; diff --git a/crates/aether-data/adapters/postgres/migrations/20260919000000_add_future_dashboard_summary.sql b/crates/aether-data/adapters/postgres/migrations/20260919000000_add_future_dashboard_summary.sql new file mode 100644 index 000000000..36d103512 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260919000000_add_future_dashboard_summary.sql @@ -0,0 +1,254 @@ +-- Future-only dashboard projection. No usage history is read or backfilled. +-- Background maintenance bounds detailed minutes to 35 days; lifetime totals +-- and narrow activity counters remain available after detail or usage cleanup. +CREATE TABLE public.dashboard_stats_state ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + stats_since timestamptz NOT NULL, + contributions_cleanup_cursor varchar(100) +); +CREATE TABLE public.dashboard_request_contributions ( + request_id varchar(100) PRIMARY KEY, + created_at timestamptz NOT NULL, + actor_user_id varchar(255), + metrics jsonb NOT NULL +); +CREATE TABLE public.dashboard_stats_total ( + shard smallint PRIMARY KEY CHECK (shard >= 0 AND shard < 16), + metrics jsonb NOT NULL DEFAULT '{}'::jsonb +); +INSERT INTO public.dashboard_stats_total(shard) SELECT generate_series(0,15); +CREATE TABLE public.dashboard_stats_minute ( + bucket_start timestamptz NOT NULL, + shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16), + metrics jsonb NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (bucket_start, shard) +); +CREATE TABLE public.dashboard_activity_minute ( + bucket_start timestamptz NOT NULL, + shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16), + request_count bigint NOT NULL, + PRIMARY KEY (bucket_start,shard) +); +CREATE TABLE public.dashboard_activity_hour ( + bucket_start timestamptz NOT NULL, + shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16), + request_count bigint NOT NULL, + PRIMARY KEY (bucket_start, shard) +); +CREATE TABLE public.dashboard_actor_minute ( + bucket_start timestamptz NOT NULL, + shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16), + actor_user_id varchar(255) NOT NULL, + request_count bigint NOT NULL, + PRIMARY KEY (bucket_start, shard, actor_user_id) +); +-- Rows only exist within a writing transaction. Deferred processing coalesces +-- usage, settlement and identity mutations into one final contribution. +CREATE TABLE public.dashboard_stats_pending ( + transaction_id bigint NOT NULL, + request_id varchar(100) NOT NULL, + deleted_fact jsonb, + PRIMARY KEY (transaction_id, request_id) +); +CREATE TABLE public.dashboard_user_events_minute ( + bucket_start timestamptz NOT NULL, + shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16), + created_count bigint NOT NULL DEFAULT 0, + deleted_count bigint NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_start, shard) +); + +CREATE FUNCTION public.dashboard_metrics_add(a jsonb, b jsonb, direction integer DEFAULT 1) +RETURNS jsonb LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb) FROM ( + SELECT key, sum(value) AS value FROM ( + SELECT key, value::numeric FROM jsonb_each_text(COALESCE(a, '{}'::jsonb)) + UNION ALL + SELECT key, value::numeric * direction FROM jsonb_each_text(COALESCE(b, '{}'::jsonb)) + ) entries GROUP BY key + ) sums +$$; + +-- Canonical views are evaluated for ONE indexed request, never for old history. +CREATE FUNCTION public.dashboard_request_fact(p_request_id text) +RETURNS jsonb LANGUAGE sql STABLE AS $$ + SELECT jsonb_build_object('created_at', f.created_at, + 'actor_user_id', f.actor_user_id, + 'metrics', CASE WHEN f.record_kind = 'session' THEN '{}'::jsonb ELSE + jsonb_build_object( + 'request_count', 1, + 'input_tokens', COALESCE(f.input_tokens,0), + 'output_tokens', COALESCE(f.output_tokens,0), + 'total_tokens', COALESCE(f.total_tokens,0), + 'usage_available_count', f.usage_available::integer, + 'pricing_available_count', (f.billable_amount IS NOT NULL)::integer, + 'billable_amount', COALESCE(f.billable_amount,0), + 'cache_read_tokens', COALESCE(f.cache_read_input_tokens,0), + 'cache_creation_tokens', COALESCE(f.cache_creation_input_tokens,0), + 'cache_input_tokens', CASE WHEN f.usage_available THEN COALESCE(b.total_input_context,0) ELSE 0 END, + 'first_byte_sum_ms', COALESCE(f.first_byte_time_ms,0), + 'first_byte_sample_count', (f.first_byte_time_ms IS NOT NULL)::integer, + 'response_sum_ms', COALESCE(f.response_time_ms,0), + 'response_sample_count', (f.response_time_ms IS NOT NULL)::integer, + 'stream_requests', COALESCE(f.upstream_is_stream,false)::integer, + 'standard_requests', (NOT COALESCE(f.upstream_is_stream,false))::integer + ) END) + FROM public.usage_analytics_facts_v1 f + JOIN public.usage_billing_facts b USING (request_id) + WHERE f.request_id = p_request_id + AND f.created_at >= (SELECT stats_since FROM public.dashboard_stats_state WHERE singleton) +$$; + +-- Callers hold the aggregate shard lock. Seed a missing narrow activity counter +-- from the detailed minute before applying a correction. +CREATE FUNCTION public.dashboard_ensure_activity_minute(p_bucket timestamptz,p_shard smallint) +RETURNS void LANGUAGE sql AS $$ + INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count) + SELECT bucket_start,shard,COALESCE((metrics->>'request_count')::bigint,0) + FROM public.dashboard_stats_minute WHERE bucket_start=p_bucket AND shard=p_shard + ON CONFLICT(bucket_start,shard) DO NOTHING +$$; + +CREATE FUNCTION public.dashboard_enqueue_request() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE rid text; +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF; + rid := CASE WHEN TG_OP = 'DELETE' THEN OLD.request_id ELSE NEW.request_id END; + -- Fast path: an update to a pre-activation request must never scan/replay it. + IF TG_TABLE_NAME = 'usage' THEN + IF (CASE WHEN TG_OP = 'DELETE' THEN OLD.created_at ELSE NEW.created_at END) + < (SELECT stats_since FROM public.dashboard_stats_state WHERE singleton) THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + INSERT INTO public.dashboard_stats_pending(transaction_id, request_id, deleted_fact) + VALUES(txid_current(), rid, + CASE WHEN TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE' + THEN public.dashboard_request_fact(rid) END) + ON CONFLICT (transaction_id, request_id) DO UPDATE + SET deleted_fact = COALESCE(EXCLUDED.deleted_fact, dashboard_stats_pending.deleted_fact); + RETURN COALESCE(NEW, OLD); +END $$; + +CREATE FUNCTION public.dashboard_apply_pending() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE p record; old_fact public.dashboard_request_contributions%ROWTYPE; + fact jsonb; next_metrics jsonb; next_at timestamptz; next_actor text; + shard_id smallint; old_bucket timestamptz; next_bucket timestamptz; purged boolean; + detail_cutoff timestamptz := date_trunc('minute',clock_timestamp()-INTERVAL '35 days'); +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN + DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id; + RETURN NULL; + END IF; + -- Lock all touched shards in one order before touching any ledger or minute. + -- This also serializes concurrent usage/settlement updates for the same request. + PERFORM t.shard FROM public.dashboard_stats_total t + WHERE t.shard IN (SELECT (hashtextextended(request_id,0) & 15)::smallint + FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id) + ORDER BY t.shard FOR UPDATE; + FOR p IN SELECT * FROM public.dashboard_stats_pending + WHERE transaction_id=NEW.transaction_id ORDER BY request_id LOOP + shard_id := (hashtextextended(p.request_id,0) & 15)::smallint; + fact := public.dashboard_request_fact(p.request_id); + purged := NOT EXISTS(SELECT 1 FROM public.usage WHERE request_id=p.request_id); + -- A purge preserves the last contribution, including an update in this tx. + fact := COALESCE(fact,p.deleted_fact); + IF fact IS NULL THEN + IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF; + CONTINUE; + END IF; + next_metrics := fact->'metrics'; + next_at := (fact->>'created_at')::timestamptz; + next_actor := CASE WHEN COALESCE((next_metrics->>'request_count')::bigint,0)>0 + THEN fact->>'actor_user_id' END; + SELECT * INTO old_fact FROM public.dashboard_request_contributions WHERE request_id=p.request_id; + IF FOUND AND old_fact.created_at=next_at + AND old_fact.actor_user_id IS NOT DISTINCT FROM next_actor + AND old_fact.metrics=next_metrics THEN + IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF; + CONTINUE; + END IF; + next_bucket := date_trunc('minute',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + IF old_fact.request_id IS NOT NULL THEN + old_bucket := date_trunc('minute',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + PERFORM public.dashboard_ensure_activity_minute(old_bucket,shard_id); + UPDATE public.dashboard_activity_minute SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0) + WHERE bucket_start=old_bucket AND shard=shard_id; + UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) WHERE shard=shard_id; + IF old_bucket>=detail_cutoff THEN + UPDATE public.dashboard_stats_minute SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) + WHERE bucket_start=old_bucket AND shard=shard_id; + END IF; + UPDATE public.dashboard_activity_hour SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0) + WHERE bucket_start=date_trunc('hour',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND shard=shard_id; + IF old_fact.actor_user_id IS NOT NULL AND old_bucket>=detail_cutoff THEN + UPDATE public.dashboard_actor_minute SET request_count=request_count-1 + WHERE bucket_start=old_bucket AND shard=shard_id AND actor_user_id=old_fact.actor_user_id; + END IF; + END IF; + PERFORM public.dashboard_ensure_activity_minute(next_bucket,shard_id); + INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count) + VALUES(next_bucket,shard_id,COALESCE((next_metrics->>'request_count')::bigint,0)) + ON CONFLICT(bucket_start,shard) DO UPDATE + SET request_count=dashboard_activity_minute.request_count+EXCLUDED.request_count; + UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,next_metrics) WHERE shard=shard_id; + IF next_bucket>=detail_cutoff THEN + INSERT INTO public.dashboard_stats_minute(bucket_start,shard,metrics) VALUES(next_bucket,shard_id,next_metrics) + ON CONFLICT(bucket_start,shard) DO UPDATE + SET metrics=public.dashboard_metrics_add(dashboard_stats_minute.metrics,EXCLUDED.metrics); + END IF; + INSERT INTO public.dashboard_activity_hour(bucket_start,shard,request_count) + VALUES(date_trunc('hour',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',shard_id,COALESCE((next_metrics->>'request_count')::bigint,0)) + ON CONFLICT(bucket_start,shard) DO UPDATE SET request_count=dashboard_activity_hour.request_count+EXCLUDED.request_count; + IF next_actor IS NOT NULL AND next_bucket>=detail_cutoff THEN + INSERT INTO public.dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count) + VALUES(next_bucket,shard_id,next_actor,1) + ON CONFLICT(bucket_start,shard,actor_user_id) DO UPDATE SET request_count=dashboard_actor_minute.request_count+1; + END IF; + IF purged THEN + DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; + ELSE + INSERT INTO public.dashboard_request_contributions(request_id,created_at,actor_user_id,metrics) + VALUES(p.request_id,next_at,next_actor,next_metrics) + ON CONFLICT(request_id) DO UPDATE SET created_at=EXCLUDED.created_at, + actor_user_id=EXCLUDED.actor_user_id,metrics=EXCLUDED.metrics; + END IF; + END LOOP; + DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id; + RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER dashboard_flush_pending AFTER INSERT ON public.dashboard_stats_pending + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dashboard_apply_pending(); + +CREATE FUNCTION public.dashboard_record_user_event() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE added bigint := 0; removed bigint := 0; rid text; at timestamptz := clock_timestamp(); +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF; + IF TG_OP='INSERT' THEN added:=1; rid:=NEW.id; + ELSIF TG_OP='DELETE' THEN removed:=(NOT OLD.is_deleted)::integer; rid:=OLD.id; + ELSE removed:=(NOT OLD.is_deleted AND NEW.is_deleted)::integer; rid:=NEW.id; + END IF; + IF added=0 AND removed=0 THEN RETURN COALESCE(NEW,OLD); END IF; + IF NOT EXISTS(SELECT 1 FROM public.dashboard_stats_state WHERE singleton AND stats_since<=at) THEN RETURN COALESCE(NEW,OLD); END IF; + INSERT INTO public.dashboard_user_events_minute(bucket_start,shard,created_count,deleted_count) + VALUES(date_trunc('minute',at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC', + (hashtextextended(rid,0) & 15)::smallint,added,removed) + ON CONFLICT(bucket_start,shard) DO UPDATE SET + created_count=dashboard_user_events_minute.created_count+EXCLUDED.created_count, + deleted_count=dashboard_user_events_minute.deleted_count+EXCLUDED.deleted_count; + RETURN COALESCE(NEW,OLD); +END $$; + +-- Acquire source DDL locks before recording activation. This excludes transactions +-- that could commit writes without observing the new triggers. +CREATE TRIGGER dashboard_usage_changed AFTER INSERT OR UPDATE ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request(); +CREATE TRIGGER aaa_dashboard_usage_deleted BEFORE DELETE ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request(); +CREATE TRIGGER dashboard_settlement_changed AFTER INSERT OR UPDATE OR DELETE ON public.usage_settlement_snapshots + FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request(); +CREATE TRIGGER dashboard_attribution_changed AFTER INSERT OR UPDATE ON public.usage_attribution_snapshots + FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_request(); +CREATE TRIGGER dashboard_user_changed AFTER INSERT OR DELETE OR UPDATE OF is_deleted ON public.users + FOR EACH ROW EXECUTE FUNCTION public.dashboard_record_user_event(); +INSERT INTO public.dashboard_stats_state(singleton,stats_since) VALUES(true,clock_timestamp()); diff --git a/crates/aether-data/adapters/postgres/migrations/20260920000000_index_credited_user_payments.sql b/crates/aether-data/adapters/postgres/migrations/20260920000000_index_credited_user_payments.sql new file mode 100644 index 000000000..e7b9b6e52 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260920000000_index_credited_user_payments.sql @@ -0,0 +1,5 @@ +-- no-transaction +-- User finance reports use the actual credited period, not order creation time. +-- Keep the index build separate from transactional schema changes. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payment_orders_status_credited_user + ON public.payment_orders USING btree (status, credited_at, user_id); diff --git a/crates/aether-data/adapters/postgres/migrations/20260920120000_add_provider_expenses.sql b/crates/aether-data/adapters/postgres/migrations/20260920120000_add_provider_expenses.sql new file mode 100644 index 000000000..3e770bc5d --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260920120000_add_provider_expenses.sql @@ -0,0 +1,25 @@ +-- Manual purchasing ledger; no foreign-key cascade may erase historical expenditures. +CREATE TABLE IF NOT EXISTS public.provider_expenses ( + id text PRIMARY KEY, + client_request_id text NOT NULL UNIQUE, + provider_id text NOT NULL, + provider_name text NOT NULL, + kind text NOT NULL CHECK (kind IN ('recharge', 'subscription', 'other')), + amount numeric(20,8) NOT NULL CHECK (amount > 0), + currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'), + paid_at timestamptz NOT NULL, + period_start timestamptz, + period_end timestamptz, + note text, + external_reference text, + created_by text, + created_at timestamptz NOT NULL DEFAULT NOW(), + voided_at timestamptz, + voided_by text, + CONSTRAINT provider_expenses_period_check CHECK ( + (period_start IS NULL AND period_end IS NULL) OR + (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end) + ) +); +CREATE INDEX IF NOT EXISTS ix_provider_expenses_paid_at ON public.provider_expenses(paid_at, id); +CREATE INDEX IF NOT EXISTS ix_provider_expenses_provider_paid_at ON public.provider_expenses(provider_id, paid_at); diff --git a/crates/aether-data/adapters/postgres/migrations/20260921010000_bound_dashboard_projection_retention.sql b/crates/aether-data/adapters/postgres/migrations/20260921010000_bound_dashboard_projection_retention.sql new file mode 100644 index 000000000..3ab5d6581 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260921010000_bound_dashboard_projection_retention.sql @@ -0,0 +1,147 @@ +-- No raw usage history is scanned. Existing dashboard minutes are compacted by +-- bounded background batches; lifetime totals and local-calendar activity survive. +-- Fresh installations already contain these definitions in the dashboard baseline. +ALTER TABLE public.dashboard_stats_state ADD COLUMN IF NOT EXISTS contributions_cleanup_cursor varchar(100); +CREATE TABLE IF NOT EXISTS public.dashboard_activity_minute ( + bucket_start timestamptz NOT NULL, + shard smallint NOT NULL CHECK (shard >= 0 AND shard < 16), + request_count bigint NOT NULL, + PRIMARY KEY (bucket_start,shard) +); + +-- Callers hold the aggregate shard lock. Before the first post-upgrade correction +-- of a minute, seed its narrow counter from the existing wide aggregate. +CREATE OR REPLACE FUNCTION public.dashboard_ensure_activity_minute(p_bucket timestamptz,p_shard smallint) +RETURNS void LANGUAGE sql AS $$ + INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count) + SELECT bucket_start,shard,COALESCE((metrics->>'request_count')::bigint,0) + FROM public.dashboard_stats_minute WHERE bucket_start=p_bucket AND shard=p_shard + ON CONFLICT(bucket_start,shard) DO NOTHING +$$; + +CREATE OR REPLACE FUNCTION public.dashboard_enqueue_request() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE rid text; +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF; + rid := CASE WHEN TG_OP = 'DELETE' THEN OLD.request_id ELSE NEW.request_id END; + -- Fast path: an update to a pre-activation request must never scan/replay it. + IF TG_TABLE_NAME = 'usage' THEN + IF (CASE WHEN TG_OP = 'DELETE' THEN OLD.created_at ELSE NEW.created_at END) + < (SELECT stats_since FROM public.dashboard_stats_state WHERE singleton) THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + INSERT INTO public.dashboard_stats_pending(transaction_id, request_id, deleted_fact) + VALUES(txid_current(), rid, + CASE WHEN TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE' + THEN public.dashboard_request_fact(rid) END) + ON CONFLICT (transaction_id, request_id) DO UPDATE + SET deleted_fact = COALESCE(EXCLUDED.deleted_fact, dashboard_stats_pending.deleted_fact); + RETURN COALESCE(NEW, OLD); +END $$; + +CREATE OR REPLACE FUNCTION public.dashboard_apply_pending() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE p record; old_fact public.dashboard_request_contributions%ROWTYPE; + fact jsonb; next_metrics jsonb; next_at timestamptz; next_actor text; + shard_id smallint; old_bucket timestamptz; next_bucket timestamptz; purged boolean; + detail_cutoff timestamptz := date_trunc('minute',clock_timestamp()-INTERVAL '35 days'); +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN + DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id; + RETURN NULL; + END IF; + -- Lock all touched shards in one order before touching any ledger or minute. + -- This also serializes concurrent usage/settlement updates for the same request. + PERFORM t.shard FROM public.dashboard_stats_total t + WHERE t.shard IN (SELECT (hashtextextended(request_id,0) & 15)::smallint + FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id) + ORDER BY t.shard FOR UPDATE; + FOR p IN SELECT * FROM public.dashboard_stats_pending + WHERE transaction_id=NEW.transaction_id ORDER BY request_id LOOP + shard_id := (hashtextextended(p.request_id,0) & 15)::smallint; + fact := public.dashboard_request_fact(p.request_id); + purged := NOT EXISTS(SELECT 1 FROM public.usage WHERE request_id=p.request_id); + -- A purge preserves the last contribution, including an update in this tx. + fact := COALESCE(fact,p.deleted_fact); + IF fact IS NULL THEN + IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF; + CONTINUE; + END IF; + next_metrics := fact->'metrics'; + next_at := (fact->>'created_at')::timestamptz; + next_actor := CASE WHEN COALESCE((next_metrics->>'request_count')::bigint,0)>0 + THEN fact->>'actor_user_id' END; + SELECT * INTO old_fact FROM public.dashboard_request_contributions WHERE request_id=p.request_id; + IF FOUND AND old_fact.created_at=next_at + AND old_fact.actor_user_id IS NOT DISTINCT FROM next_actor + AND old_fact.metrics=next_metrics THEN + IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF; + CONTINUE; + END IF; + next_bucket := date_trunc('minute',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + IF old_fact.request_id IS NOT NULL THEN + old_bucket := date_trunc('minute',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + PERFORM public.dashboard_ensure_activity_minute(old_bucket,shard_id); + UPDATE public.dashboard_activity_minute SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0) + WHERE bucket_start=old_bucket AND shard=shard_id; + UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) WHERE shard=shard_id; + IF old_bucket>=detail_cutoff THEN + UPDATE public.dashboard_stats_minute SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) + WHERE bucket_start=old_bucket AND shard=shard_id; + END IF; + UPDATE public.dashboard_activity_hour SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0) + WHERE bucket_start=date_trunc('hour',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND shard=shard_id; + IF old_fact.actor_user_id IS NOT NULL AND old_bucket>=detail_cutoff THEN + UPDATE public.dashboard_actor_minute SET request_count=request_count-1 + WHERE bucket_start=old_bucket AND shard=shard_id AND actor_user_id=old_fact.actor_user_id; + END IF; + END IF; + PERFORM public.dashboard_ensure_activity_minute(next_bucket,shard_id); + INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count) + VALUES(next_bucket,shard_id,COALESCE((next_metrics->>'request_count')::bigint,0)) + ON CONFLICT(bucket_start,shard) DO UPDATE + SET request_count=dashboard_activity_minute.request_count+EXCLUDED.request_count; + UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,next_metrics) WHERE shard=shard_id; + IF next_bucket>=detail_cutoff THEN + INSERT INTO public.dashboard_stats_minute(bucket_start,shard,metrics) VALUES(next_bucket,shard_id,next_metrics) + ON CONFLICT(bucket_start,shard) DO UPDATE + SET metrics=public.dashboard_metrics_add(dashboard_stats_minute.metrics,EXCLUDED.metrics); + END IF; + INSERT INTO public.dashboard_activity_hour(bucket_start,shard,request_count) + VALUES(date_trunc('hour',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',shard_id,COALESCE((next_metrics->>'request_count')::bigint,0)) + ON CONFLICT(bucket_start,shard) DO UPDATE SET request_count=dashboard_activity_hour.request_count+EXCLUDED.request_count; + IF next_actor IS NOT NULL AND next_bucket>=detail_cutoff THEN + INSERT INTO public.dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count) + VALUES(next_bucket,shard_id,next_actor,1) + ON CONFLICT(bucket_start,shard,actor_user_id) DO UPDATE SET request_count=dashboard_actor_minute.request_count+1; + END IF; + IF purged THEN + DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; + ELSE + INSERT INTO public.dashboard_request_contributions(request_id,created_at,actor_user_id,metrics) + VALUES(p.request_id,next_at,next_actor,next_metrics) + ON CONFLICT(request_id) DO UPDATE SET created_at=EXCLUDED.created_at, + actor_user_id=EXCLUDED.actor_user_id,metrics=EXCLUDED.metrics; + END IF; + END LOOP; + DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id; + RETURN NULL; +END $$; +CREATE OR REPLACE FUNCTION public.dashboard_record_user_event() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE added bigint := 0; removed bigint := 0; rid text; at timestamptz := clock_timestamp(); +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN COALESCE(NEW,OLD); END IF; + IF TG_OP='INSERT' THEN added:=1; rid:=NEW.id; + ELSIF TG_OP='DELETE' THEN removed:=(NOT OLD.is_deleted)::integer; rid:=OLD.id; + ELSE removed:=(NOT OLD.is_deleted AND NEW.is_deleted)::integer; rid:=NEW.id; + END IF; + IF added=0 AND removed=0 THEN RETURN COALESCE(NEW,OLD); END IF; + IF NOT EXISTS(SELECT 1 FROM public.dashboard_stats_state WHERE singleton AND stats_since<=at) THEN RETURN COALESCE(NEW,OLD); END IF; + INSERT INTO public.dashboard_user_events_minute(bucket_start,shard,created_count,deleted_count) + VALUES(date_trunc('minute',at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC', + (hashtextextended(rid,0) & 15)::smallint,added,removed) + ON CONFLICT(bucket_start,shard) DO UPDATE SET + created_count=dashboard_user_events_minute.created_count+EXCLUDED.created_count, + deleted_count=dashboard_user_events_minute.deleted_count+EXCLUDED.deleted_count; + RETURN COALESCE(NEW,OLD); +END $$; diff --git a/crates/aether-data/adapters/postgres/migrations/20260921020000_index_usage_attribution_owner.sql b/crates/aether-data/adapters/postgres/migrations/20260921020000_index_usage_attribution_owner.sql new file mode 100644 index 000000000..69dbb5b2f --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260921020000_index_usage_attribution_owner.sql @@ -0,0 +1,5 @@ +-- no-transaction +-- Anonymization looks up either side of the account attribution. Keep both +-- branches indexed so deleting an unrelated user never scans all snapshots. +CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_usage_attribution_owner_request + ON public.usage_attribution_snapshots (credential_owner_id, request_id); diff --git a/crates/aether-data/adapters/postgres/migrations/20260921020100_index_usage_attribution_metadata_actor.sql b/crates/aether-data/adapters/postgres/migrations/20260921020100_index_usage_attribution_metadata_actor.sql new file mode 100644 index 000000000..ae5f580ed --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20260921020100_index_usage_attribution_metadata_actor.sql @@ -0,0 +1,6 @@ +-- no-transaction +-- Historical trusted-identity metadata must also be anonymized. Index that +-- exact predicate without storing the much larger metadata document. +CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_usage_analytics_actor_metadata + ON public.usage ((request_metadata #>> '{analytics_attribution,actor_user_id}')) + WHERE (request_metadata #>> '{analytics_attribution,actor_user_id}') IS NOT NULL; diff --git a/crates/aether-data/adapters/postgres/migrations/20261001000000_anonymize_dashboard_users.sql b/crates/aether-data/adapters/postgres/migrations/20261001000000_anonymize_dashboard_users.sql new file mode 100644 index 000000000..d58c41ea8 --- /dev/null +++ b/crates/aether-data/adapters/postgres/migrations/20261001000000_anonymize_dashboard_users.sql @@ -0,0 +1,139 @@ +-- Install future user anonymization without scanning or rewriting historical rows. +-- Legacy orphan actors are excluded by the dashboard read path. User deletion +-- cleans the retained actor window and contribution identities at transaction end. +CREATE TABLE public.dashboard_user_anonymization_pending ( + transaction_id bigint NOT NULL, + user_id varchar(255) NOT NULL, + PRIMARY KEY (transaction_id,user_id) +); + +CREATE FUNCTION public.dashboard_enqueue_user_anonymization() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN RETURN NULL; END IF; + IF TG_OP='DELETE' OR NEW.is_deleted THEN + INSERT INTO public.dashboard_user_anonymization_pending(transaction_id,user_id) + VALUES(txid_current(),OLD.id) ON CONFLICT DO NOTHING; + END IF; + RETURN NULL; +END $$; +CREATE TRIGGER dashboard_user_anonymize AFTER DELETE OR UPDATE OF is_deleted ON public.users + FOR EACH ROW EXECUTE FUNCTION public.dashboard_enqueue_user_anonymization(); + +CREATE OR REPLACE FUNCTION public.dashboard_apply_pending() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE p record; anonymous_request record; old_fact public.dashboard_request_contributions%ROWTYPE; + fact jsonb; next_metrics jsonb; next_at timestamptz; next_actor text; + shard_id smallint; old_bucket timestamptz; next_bucket timestamptz; purged boolean; anonymize boolean; + detail_cutoff timestamptz := date_trunc('minute',clock_timestamp()-INTERVAL '35 days'); +BEGIN + IF current_setting('aether.dashboard_restore',true)='on' THEN + DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id; + DELETE FROM public.dashboard_user_anonymization_pending WHERE transaction_id=NEW.transaction_id; + RETURN NULL; + END IF; + SELECT EXISTS (SELECT 1 FROM public.dashboard_user_anonymization_pending + WHERE transaction_id=NEW.transaction_id) INTO anonymize; + -- Lock all touched shards in one order before touching any ledger or minute. + -- This also serializes concurrent usage/settlement updates for the same request. + PERFORM t.shard FROM public.dashboard_stats_total t + WHERE t.shard IN (SELECT (hashtextextended(request_id,0) & 15)::smallint + FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id) + OR anonymize + ORDER BY t.shard FOR UPDATE; + FOR p IN SELECT * FROM public.dashboard_stats_pending + WHERE transaction_id=NEW.transaction_id ORDER BY request_id LOOP + shard_id := (hashtextextended(p.request_id,0) & 15)::smallint; + fact := public.dashboard_request_fact(p.request_id); + purged := NOT EXISTS(SELECT 1 FROM public.usage WHERE request_id=p.request_id); + -- A purge preserves the last contribution, including an update in this tx. + fact := COALESCE(fact,p.deleted_fact); + IF fact IS NULL THEN + IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF; + CONTINUE; + END IF; + next_metrics := fact->'metrics'; + next_at := (fact->>'created_at')::timestamptz; + next_actor := CASE WHEN COALESCE((next_metrics->>'request_count')::bigint,0)>0 + THEN (SELECT id FROM public.users WHERE id=fact->>'actor_user_id' AND NOT is_deleted) END; + SELECT * INTO old_fact FROM public.dashboard_request_contributions WHERE request_id=p.request_id; + IF FOUND AND old_fact.created_at=next_at + AND old_fact.actor_user_id IS NOT DISTINCT FROM next_actor + AND old_fact.metrics=next_metrics THEN + IF purged THEN DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; END IF; + CONTINUE; + END IF; + next_bucket := date_trunc('minute',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + IF old_fact.request_id IS NOT NULL THEN + old_bucket := date_trunc('minute',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + PERFORM public.dashboard_ensure_activity_minute(old_bucket,shard_id); + UPDATE public.dashboard_activity_minute SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0) + WHERE bucket_start=old_bucket AND shard=shard_id; + UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) WHERE shard=shard_id; + IF old_bucket>=detail_cutoff THEN + UPDATE public.dashboard_stats_minute SET metrics=public.dashboard_metrics_add(metrics,old_fact.metrics,-1) + WHERE bucket_start=old_bucket AND shard=shard_id; + END IF; + UPDATE public.dashboard_activity_hour SET request_count=request_count-COALESCE((old_fact.metrics->>'request_count')::bigint,0) + WHERE bucket_start=date_trunc('hour',old_fact.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND shard=shard_id; + IF old_fact.actor_user_id IS NOT NULL AND old_bucket>=detail_cutoff THEN + UPDATE public.dashboard_actor_minute SET request_count=request_count-1 + WHERE bucket_start=old_bucket AND shard=shard_id AND actor_user_id=old_fact.actor_user_id; + END IF; + END IF; + PERFORM public.dashboard_ensure_activity_minute(next_bucket,shard_id); + INSERT INTO public.dashboard_activity_minute(bucket_start,shard,request_count) + VALUES(next_bucket,shard_id,COALESCE((next_metrics->>'request_count')::bigint,0)) + ON CONFLICT(bucket_start,shard) DO UPDATE + SET request_count=dashboard_activity_minute.request_count+EXCLUDED.request_count; + UPDATE public.dashboard_stats_total SET metrics=public.dashboard_metrics_add(metrics,next_metrics) WHERE shard=shard_id; + IF next_bucket>=detail_cutoff THEN + INSERT INTO public.dashboard_stats_minute(bucket_start,shard,metrics) VALUES(next_bucket,shard_id,next_metrics) + ON CONFLICT(bucket_start,shard) DO UPDATE + SET metrics=public.dashboard_metrics_add(dashboard_stats_minute.metrics,EXCLUDED.metrics); + END IF; + INSERT INTO public.dashboard_activity_hour(bucket_start,shard,request_count) + VALUES(date_trunc('hour',next_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC',shard_id,COALESCE((next_metrics->>'request_count')::bigint,0)) + ON CONFLICT(bucket_start,shard) DO UPDATE SET request_count=dashboard_activity_hour.request_count+EXCLUDED.request_count; + IF next_actor IS NOT NULL AND next_bucket>=detail_cutoff THEN + INSERT INTO public.dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count) + VALUES(next_bucket,shard_id,next_actor,1) + ON CONFLICT(bucket_start,shard,actor_user_id) DO UPDATE SET request_count=dashboard_actor_minute.request_count+1; + END IF; + IF purged THEN + DELETE FROM public.dashboard_request_contributions WHERE request_id=p.request_id; + ELSE + INSERT INTO public.dashboard_request_contributions(request_id,created_at,actor_user_id,metrics) + VALUES(p.request_id,next_at,next_actor,next_metrics) + ON CONFLICT(request_id) DO UPDATE SET created_at=EXCLUDED.created_at, + actor_user_id=EXCLUDED.actor_user_id,metrics=EXCLUDED.metrics; + END IF; + END LOOP; + -- Flush identity removal only after request corrections, under the same shard + -- locks. This also covers requests whose source usage was already purged. + -- Surviving contribution identities were corrected above by request_id; do + -- not scan the lifetime contribution table while holding the shard locks. + IF anonymize THEN + -- A writer can commit between the user's deletion statement and this flush. + -- Its new attribution was invisible to the user's earlier trigger. Reuse + -- the actor index to find only those requests, then clear each ledger by PK. + -- Do not lock attribution rows here: writers lock them before their shard. + FOR anonymous_request IN + SELECT a.request_id, pending_user.user_id + FROM public.dashboard_user_anonymization_pending pending_user + JOIN public.usage_attribution_snapshots a ON a.actor_user_id=pending_user.user_id + WHERE pending_user.transaction_id=NEW.transaction_id + LOOP + UPDATE public.dashboard_request_contributions SET actor_user_id=NULL + WHERE request_id=anonymous_request.request_id AND actor_user_id=anonymous_request.user_id; + END LOOP; + DELETE FROM public.dashboard_actor_minute a USING public.dashboard_user_anonymization_pending pending_user + WHERE pending_user.transaction_id=NEW.transaction_id AND a.actor_user_id=pending_user.user_id; + END IF; + DELETE FROM public.dashboard_stats_pending WHERE transaction_id=NEW.transaction_id; + DELETE FROM public.dashboard_user_anonymization_pending WHERE transaction_id=NEW.transaction_id; + RETURN NULL; +END $$; +-- Both request and user events share one deferred flush. It locks every touched +-- shard in order before corrections or deletion, regardless of trigger order. +CREATE CONSTRAINT TRIGGER dashboard_flush_user_anonymization + AFTER INSERT ON public.dashboard_user_anonymization_pending + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.dashboard_apply_pending(); diff --git a/crates/aether-data/adapters/postgres/src/announcements.rs b/crates/aether-data/adapters/postgres/src/announcements.rs index 89ea076a5..fc6bec51f 100644 --- a/crates/aether-data/adapters/postgres/src/announcements.rs +++ b/crates/aether-data/adapters/postgres/src/announcements.rs @@ -60,6 +60,48 @@ ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC LIMIT $3 "#; +const LIST_USER_ANNOUNCEMENTS_SQL: &str = r#" +WITH visible AS MATERIALIZED ( + SELECT a.*, EXISTS ( + SELECT 1 FROM announcement_reads r WHERE r.user_id = $1 AND r.announcement_id = a.id + ) AS is_read + FROM announcements a + WHERE a.is_active = TRUE + AND (a.start_time IS NULL OR a.start_time <= TO_TIMESTAMP($2::double precision)) + AND (a.end_time IS NULL OR a.end_time >= TO_TIMESTAMP($2::double precision)) +), counts AS ( + SELECT count(*) FILTER (WHERE NOT $3 OR NOT is_read)::bigint AS total, + count(*) FILTER (WHERE NOT is_read)::bigint AS unread_count + FROM visible +) +SELECT + counts.total, + counts.unread_count, + a.id, + a.title, + a.content, + a.type, + a.priority, + a.is_active, + a.is_pinned, + a.requires_ack, + a.is_read, + a.author_id, + u.username AS author_username, + EXTRACT(EPOCH FROM a.start_time)::bigint AS start_time_unix_secs, + EXTRACT(EPOCH FROM a.end_time)::bigint AS end_time_unix_secs, + EXTRACT(EPOCH FROM a.created_at)::bigint AS created_at_unix_ms, + EXTRACT(EPOCH FROM a.updated_at)::bigint AS updated_at_unix_secs +FROM counts +LEFT JOIN LATERAL ( + SELECT * FROM visible WHERE NOT $3 OR NOT is_read + ORDER BY is_pinned DESC, priority DESC, created_at DESC, id ASC + LIMIT $4 OFFSET $5 +) a ON TRUE +LEFT JOIN users u ON u.id = a.author_id +ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC +"#; + const CREATE_ANNOUNCEMENT_SQL: &str = r#" INSERT INTO announcements ( id, @@ -262,6 +304,40 @@ impl AnnouncementReadRepository for SqlxAnnouncementReadRepository { Ok(StoredAnnouncementPage { items, total }) } + async fn list_user_announcements( + &self, + user_id: &str, + query: &UserAnnouncementListQuery, + ) -> Result { + query.validate()?; + // A single statement preserves counts even when the selected page is empty. + let rows = sqlx::query(LIST_USER_ANNOUNCEMENTS_SQL) + .bind(user_id) + .bind(query.now_unix_secs as f64) + .bind(query.unread_only) + .bind(query.limit as i64) + .bind(query.offset as i64) + .fetch_all(&self.pool) + .await + .map_postgres_err()?; + let mut page = StoredUserAnnouncementPage::default(); + for row in rows { + page.total = row.try_get::("total").map_postgres_err()? as u64; + page.unread_count = row.try_get::("unread_count").map_postgres_err()? as u64; + if row + .try_get::, _>("id") + .map_postgres_err()? + .is_some() + { + page.items.push(StoredUserAnnouncement { + announcement: map_announcement_row(&row)?, + is_read: row.try_get("is_read").map_postgres_err()?, + }); + } + } + Ok(page) + } + async fn count_unread_active_announcements( &self, user_id: &str, @@ -421,6 +497,103 @@ mod tests { use super::SqlxAnnouncementReadRepository; use crate::{PostgresPoolConfig, PostgresPoolFactory}; + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL; uses connection-local temporary tables"] + async fn live_personal_announcement_page_preserves_global_unread_and_visibility() { + use aether_data_contracts::repository::announcements::{ + AnnouncementReadRepository, AnnouncementWriteRepository, UserAnnouncementListQuery, + }; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + sqlx::raw_sql( + "CREATE TEMP TABLE announcements (LIKE public.announcements INCLUDING ALL); + CREATE TEMP TABLE announcement_reads (LIKE public.announcement_reads INCLUDING ALL); + CREATE TEMP TABLE users (id varchar(36) PRIMARY KEY, username varchar(255));", + ) + .execute(&pool) + .await + .unwrap(); + let repository = SqlxAnnouncementReadRepository::new(pool.clone()); + let now = 1_800_000_000_i64; + for (id, active, pinned, priority, start, end) in [ + ("pinned", true, true, 0, None, None), + ("normal-a", true, false, 20, Some(now), Some(now)), + ("normal-b", true, false, 20, None, None), + ("draft", false, false, 99, None, None), + ("future", true, false, 99, Some(now + 1), None), + ("expired", true, false, 99, None, Some(now - 1)), + ] { + sqlx::query("INSERT INTO announcements(id,title,content,type,priority,is_active,is_pinned,created_at,updated_at,start_time,end_time) VALUES($1,$1,'content','info',$2,$3,$4,TO_TIMESTAMP($5::double precision),TO_TIMESTAMP($5::double precision),TO_TIMESTAMP($6::double precision),TO_TIMESTAMP($7::double precision))") + .bind(id).bind(priority).bind(active).bind(pinned).bind(now as f64) + .bind(start.map(|value| value as f64)).bind(end.map(|value| value as f64)) + .execute(&pool).await.unwrap(); + } + repository + .mark_announcement_as_read("reader", "pinned", now as u64) + .await + .unwrap(); + let mut query = UserAnnouncementListQuery { + unread_only: false, + offset: 0, + limit: 1, + now_unix_secs: now as u64, + }; + let first = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert_eq!((first.total, first.unread_count), (3, 2)); + assert_eq!(first.items[0].announcement.id, "pinned"); + assert!(first.items[0].is_read); + query.unread_only = true; + query.offset = 1; + let second = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert_eq!((second.total, second.unread_count), (2, 2)); + assert_eq!(second.items[0].announcement.id, "normal-b"); + assert!(!second.items[0].is_read); + query.offset = i64::MAX as usize; + let empty = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert!(empty.items.is_empty()); + assert_eq!((empty.total, empty.unread_count), (2, 2)); + let other = repository + .list_user_announcements("other", &query) + .await + .unwrap(); + assert_eq!((other.total, other.unread_count), (3, 3)); + repository + .mark_announcement_as_read("reader", "normal-a", now as u64) + .await + .unwrap(); + query.offset = 0; + let after_read = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert_eq!((after_read.total, after_read.unread_count), (1, 1)); + assert_eq!( + repository + .count_unread_active_announcements("reader", now as u64) + .await + .unwrap(), + 1 + ); + query.limit = 101; + assert!(repository + .list_user_announcements("reader", &query) + .await + .is_err()); + pool.close().await; + } + #[tokio::test] async fn repository_constructs_from_lazy_pool() { let factory = PostgresPoolFactory::new(PostgresPoolConfig { diff --git a/crates/aether-data/adapters/postgres/src/billing.rs b/crates/aether-data/adapters/postgres/src/billing.rs index 8cc7455ef..285ae3e55 100644 --- a/crates/aether-data/adapters/postgres/src/billing.rs +++ b/crates/aether-data/adapters/postgres/src/billing.rs @@ -1,3 +1,4 @@ +mod provider_expenses; use async_trait::async_trait; use sqlx::{PgPool, Row}; @@ -154,6 +155,38 @@ impl SqlxBillingReadRepository { #[async_trait] impl BillingReadRepository for SqlxBillingReadRepository { + async fn list_provider_expenses( + &self, + query: &aether_data_contracts::repository::billing::ProviderExpenseQuery, + ) -> Result< + Option, + DataLayerError, + > { + self.expense_page(query).await + } + async fn create_provider_expense( + &self, + input: &aether_data_contracts::repository::billing::ProviderExpenseInput, + ) -> Result< + AdminBillingMutationOutcome< + aether_data_contracts::repository::billing::ProviderExpenseRecord, + >, + DataLayerError, + > { + self.insert_expense(input).await + } + async fn void_provider_expense( + &self, + id: &str, + operator: Option<&str>, + ) -> Result< + AdminBillingMutationOutcome< + aether_data_contracts::repository::billing::ProviderExpenseRecord, + >, + DataLayerError, + > { + self.void_expense(id, operator).await + } async fn find_model_context( &self, provider_id: &str, @@ -1086,6 +1119,15 @@ WHERE product_id = $1 async fn list_user_plan_entitlements( &self, user_id: &str, + ) -> Result>, DataLayerError> { + self.list_user_plan_entitlements_with_history(user_id, false) + .await + } + + async fn list_user_plan_entitlements_with_history( + &self, + user_id: &str, + include_inactive: bool, ) -> Result>, DataLayerError> { let rows = sqlx::query( r#" @@ -1098,12 +1140,12 @@ SELECT CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs FROM user_plan_entitlements WHERE user_id = $1 - AND status = 'active' - AND expires_at > NOW() + AND ($2 OR (status = 'active' AND expires_at > NOW())) ORDER BY expires_at ASC, created_at ASC "#, ) .bind(user_id) + .bind(include_inactive) .fetch_all(&self.pool) .await .map_postgres_err()?; diff --git a/crates/aether-data/adapters/postgres/src/billing/provider_expenses.rs b/crates/aether-data/adapters/postgres/src/billing/provider_expenses.rs new file mode 100644 index 000000000..be0fd006b --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/billing/provider_expenses.rs @@ -0,0 +1,203 @@ +use super::SqlxBillingReadRepository; +use crate::error::SqlxResultExt; +use aether_data_contracts::{repository::billing::*, DataLayerError}; +use sqlx::Row; + +const SELECT_FIELDS: &str = "id, client_request_id, provider_id, provider_name, kind, amount::text AS amount, currency, (EXTRACT(EPOCH FROM paid_at)*1000)::bigint AS paid_ms, (EXTRACT(EPOCH FROM period_start)*1000)::bigint AS period_start_ms, (EXTRACT(EPOCH FROM period_end)*1000)::bigint AS period_end_ms, note, external_reference, created_by, (EXTRACT(EPOCH FROM created_at)*1000)::bigint AS created_ms, (EXTRACT(EPOCH FROM voided_at)*1000)::bigint AS voided_ms, voided_by"; +fn row_record(row: &sqlx::postgres::PgRow) -> Result { + Ok(ProviderExpenseRecord { + id: row.try_get("id").map_postgres_err()?, + entry: ProviderExpenseInput { + client_request_id: row.try_get("client_request_id").map_postgres_err()?, + provider_id: row.try_get("provider_id").map_postgres_err()?, + provider_name: row.try_get("provider_name").map_postgres_err()?, + kind: row.try_get("kind").map_postgres_err()?, + amount: row.try_get("amount").map_postgres_err()?, + currency: row.try_get("currency").map_postgres_err()?, + paid_at_unix_ms: row.try_get::("paid_ms").map_postgres_err()? as u64, + period_start_unix_ms: row + .try_get::, _>("period_start_ms") + .map_postgres_err()? + .map(|v| v as u64), + period_end_unix_ms: row + .try_get::, _>("period_end_ms") + .map_postgres_err()? + .map(|v| v as u64), + note: row.try_get("note").map_postgres_err()?, + external_reference: row.try_get("external_reference").map_postgres_err()?, + created_by: row.try_get("created_by").map_postgres_err()?, + }, + created_at_unix_ms: row.try_get::("created_ms").map_postgres_err()? as u64, + voided_at_unix_ms: row + .try_get::, _>("voided_ms") + .map_postgres_err()? + .map(|v| v as u64), + voided_by: row.try_get("voided_by").map_postgres_err()?, + }) +} +fn datetime(ms: u64) -> chrono::DateTime { + chrono::DateTime::from_timestamp_millis(ms as i64).expect("validated expense timestamp") +} +impl SqlxBillingReadRepository { + pub(super) async fn expense_page( + &self, + query: &ProviderExpenseQuery, + ) -> Result, DataLayerError> { + query.validate()?; + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let from = datetime(query.from_unix_ms); + let to = datetime(query.to_unix_ms); + let totals = sqlx::query(r#" + SELECT currency, sum(amount)::text AS amount, + COALESCE(sum(amount) FILTER (WHERE kind='recharge'),0)::text AS recharge_amount, + COALESCE(sum(amount) FILTER (WHERE kind='subscription'),0)::text AS subscription_amount, + COALESCE(sum(amount) FILTER (WHERE kind='other'),0)::text AS other_amount, + count(*)::bigint AS entry_count + FROM provider_expenses + WHERE voided_at IS NULL AND paid_at >= $1 AND paid_at < $2 + GROUP BY currency ORDER BY currency + "#) + .bind(from) + .bind(to) + .fetch_all(&mut *tx) + .await.map_postgres_err()? + .iter() + .map(|r| Ok(ProviderExpenseTotals { + currency: r.try_get("currency").map_postgres_err()?, + amount: r.try_get("amount").map_postgres_err()?, + recharge_amount: r.try_get("recharge_amount").map_postgres_err()?, + subscription_amount: r.try_get("subscription_amount").map_postgres_err()?, + other_amount: r.try_get("other_amount").map_postgres_err()?, + entry_count: r.try_get::("entry_count").map_postgres_err()? as u64, + })) + .collect::,DataLayerError>>()?; + let providers = sqlx::query( + r#" + SELECT provider_id, + (array_agg(provider_name ORDER BY paid_at DESC,id DESC))[1] AS provider_name, + currency, sum(amount)::text AS amount, count(*)::bigint AS entry_count + FROM provider_expenses + WHERE voided_at IS NULL AND paid_at >= $1 AND paid_at < $2 + GROUP BY provider_id,currency ORDER BY provider_id,currency + "#, + ) + .bind(from) + .bind(to) + .fetch_all(&mut *tx) + .await + .map_postgres_err()? + .iter() + .map(|r| { + Ok(ProviderExpenseProviderTotal { + provider_id: r.try_get("provider_id").map_postgres_err()?, + provider_name: r.try_get("provider_name").map_postgres_err()?, + currency: r.try_get("currency").map_postgres_err()?, + amount: r.try_get("amount").map_postgres_err()?, + entry_count: r.try_get::("entry_count").map_postgres_err()? as u64, + }) + }) + .collect::, DataLayerError>>()?; + let items = sqlx::query(&format!( + r#" + SELECT {SELECT_FIELDS} FROM provider_expenses + WHERE voided_at IS NULL AND paid_at >= $1 AND paid_at < $2 + ORDER BY paid_at DESC,id DESC LIMIT $3 OFFSET $4 + "# + )) + .bind(from) + .bind(to) + .bind(i64::from(query.limit)) + .bind(query.offset as i64) + .fetch_all(&mut *tx) + .await + .map_postgres_err()? + .iter() + .map(row_record) + .collect::, _>>()?; + tx.commit().await.map_postgres_err()?; + Ok(Some(ProviderExpensePage { + items, + total: totals.iter().map(|r| r.entry_count).sum(), + totals, + providers, + })) + } + pub(super) async fn insert_expense( + &self, + input: &ProviderExpenseInput, + ) -> Result, DataLayerError> { + if let Err(detail) = input.validate() { + return Ok(AdminBillingMutationOutcome::Invalid(detail)); + } + let row = sqlx::query(&format!( + r#" + INSERT INTO provider_expenses ( + id,client_request_id,provider_id,provider_name,kind,amount,currency, + paid_at,period_start,period_end,note,external_reference,created_by + ) VALUES ($1,$2,$3,$4,$5,$6::text::numeric,$7,$8,$9,$10,$11,$12,$13) + ON CONFLICT(client_request_id) DO NOTHING RETURNING {SELECT_FIELDS} + "# + )) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&input.client_request_id) + .bind(&input.provider_id) + .bind(&input.provider_name) + .bind(&input.kind) + .bind(&input.amount) + .bind(&input.currency) + .bind(datetime(input.paid_at_unix_ms)) + .bind(input.period_start_unix_ms.map(datetime)) + .bind(input.period_end_unix_ms.map(datetime)) + .bind(&input.note) + .bind(&input.external_reference) + .bind(&input.created_by) + .fetch_optional(&self.pool) + .await + .map_postgres_err()?; + if let Some(row) = row { + return Ok(AdminBillingMutationOutcome::Applied(row_record(&row)?)); + } + let row = sqlx::query(&format!( + "SELECT {SELECT_FIELDS} FROM provider_expenses WHERE client_request_id=$1" + )) + .bind(&input.client_request_id) + .fetch_one(&self.pool) + .await + .map_postgres_err()?; + let record = row_record(&row)?; + Ok(if record.entry.same_request_as(input) { + AdminBillingMutationOutcome::Applied(record) + } else { + AdminBillingMutationOutcome::Invalid( + "client_request_id was already used for another expense".into(), + ) + }) + } + pub(super) async fn void_expense( + &self, + id: &str, + operator: Option<&str>, + ) -> Result, DataLayerError> { + let row = sqlx::query(&format!( + r#" + UPDATE provider_expenses + SET voided_by = CASE WHEN voided_at IS NULL THEN $2 ELSE voided_by END, + voided_at = COALESCE(voided_at,NOW()) + WHERE id=$1 RETURNING {SELECT_FIELDS} + "# + )) + .bind(id) + .bind(operator) + .fetch_optional(&self.pool) + .await + .map_postgres_err()?; + row.as_ref().map(row_record).transpose().map(|r| { + r.map(AdminBillingMutationOutcome::Applied) + .unwrap_or(AdminBillingMutationOutcome::NotFound) + }) + } +} diff --git a/crates/aether-data/adapters/postgres/src/migrations.rs b/crates/aether-data/adapters/postgres/src/migrations.rs index 2525e060d..0a2eda36b 100644 --- a/crates/aether-data/adapters/postgres/src/migrations.rs +++ b/crates/aether-data/adapters/postgres/src/migrations.rs @@ -4,19 +4,21 @@ use std::pin::Pin; use sqlx::{ migrate::{AppliedMigration, Migrate, MigrateError, Migrator}, - query, query_scalar, PgConnection, PgPool, + query, query_scalar, Connection, PgConnection, PgPool, }; use tracing::{error, info, warn}; use aether_data_contracts::PendingMigrationInfo; +mod cancellation; +mod timeouts; +use cancellation::MigrationAbortGuard; +use timeouts::{with_deadline, MigrationTimeouts}; + pub static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations"); const MIGRATIONS_TABLE_EXISTS_SQL: &str = "SELECT to_regclass('public._sqlx_migrations') IS NOT NULL"; -const USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_MIGRATION_VERSION: i64 = 20260715000000; -const USAGE_SETTLEMENT_DASHBOARD_INDEX_MIGRATION_VERSION: i64 = 20260715130000; -const USAGE_STALE_PENDING_CLEANUP_INDEX_MIGRATION_VERSION: i64 = 20260720000000; -const INVALID_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_EXISTS_SQL: &str = r#" +const INVALID_CONCURRENT_INDEX_EXISTS_SQL: &str = r#" SELECT EXISTS ( SELECT 1 FROM pg_catalog.pg_class AS index_relation @@ -25,42 +27,10 @@ SELECT EXISTS ( JOIN pg_catalog.pg_index AS index_state ON index_state.indexrelid = index_relation.oid WHERE index_namespace.nspname = 'public' - AND index_relation.relname = 'idx_usage_legacy_body_ref_cleanup_created_at' + AND index_relation.relname = $1 AND NOT index_state.indisvalid ) "#; -const DROP_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_SQL: &str = - "DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_legacy_body_ref_cleanup_created_at"; -const INVALID_USAGE_SETTLEMENT_DASHBOARD_INDEX_EXISTS_SQL: &str = r#" -SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class AS index_relation - JOIN pg_catalog.pg_namespace AS index_namespace - ON index_namespace.oid = index_relation.relnamespace - JOIN pg_catalog.pg_index AS index_state - ON index_state.indexrelid = index_relation.oid - WHERE index_namespace.nspname = 'public' - AND index_relation.relname = 'idx_usage_settlement_dashboard_cover' - AND NOT index_state.indisvalid -) -"#; -const DROP_USAGE_SETTLEMENT_DASHBOARD_INDEX_SQL: &str = - "DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_settlement_dashboard_cover"; -const INVALID_USAGE_STALE_PENDING_CLEANUP_INDEX_EXISTS_SQL: &str = r#" -SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class AS index_relation - JOIN pg_catalog.pg_namespace AS index_namespace - ON index_namespace.oid = index_relation.relnamespace - JOIN pg_catalog.pg_index AS index_state - ON index_state.indexrelid = index_relation.oid - WHERE index_namespace.nspname = 'public' - AND index_relation.relname = 'idx_usage_stale_pending_created_request' - AND NOT index_state.indisvalid -) -"#; -const DROP_USAGE_STALE_PENDING_CLEANUP_INDEX_SQL: &str = - "DROP INDEX CONCURRENTLY IF EXISTS public.idx_usage_stale_pending_created_request"; pub type BootstrapFuture<'a> = Pin> + 'a>>; @@ -93,27 +63,38 @@ pub async fn run_migrations_with_bootstrap( pool: &PgPool, bootstrap: &dyn PostgresMigrationBootstrap, ) -> Result<(), MigrateError> { + let timeouts = MigrationTimeouts::from_env()?; let mut conn = crate::pool::acquire_postgres_migration_connection(pool).await?; - - if POSTGRES_MIGRATOR.locking { - conn.lock().await?; - } - - let result = run_migrations_locked(&mut conn, bootstrap).await; - - if POSTGRES_MIGRATOR.locking { - match conn.unlock().await { - Ok(()) => {} - Err(unlock_error) if result.is_ok() => return Err(unlock_error), - Err(unlock_error) => { - warn!( - error = %unlock_error, - "database migration lock release failed after migration error" - ); + let mut abort_guard = MigrationAbortGuard::new(pool, &mut conn).await?; + let result = async { + with_deadline(timeouts.transaction_ms, None, async { + timeouts.apply(&mut conn, false).await?; + if POSTGRES_MIGRATOR.locking { + conn.lock().await?; } + prepare_database_for_startup_locked(&mut conn, bootstrap).await?; + Ok(()) + }) + .await?; + run_migrations_locked(&mut conn, timeouts).await?; + if POSTGRES_MIGRATOR.locking { + conn.unlock().await?; } + Ok(()) + } + .await; + // A dropped SQLx future can leave an active/aborted transaction behind. Do + // not issue more SQL or return this connection to the pool on any error. + if result.is_err() { + abort_guard.abort().await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + conn.detach().close_hard(), + ) + .await; + } else { + abort_guard.disarm(); } - result } @@ -132,37 +113,41 @@ pub async fn prepare_database_for_startup_with_bootstrap( pool: &PgPool, bootstrap: &dyn PostgresMigrationBootstrap, ) -> Result, MigrateError> { + let timeouts = MigrationTimeouts::from_env()?; let mut conn = crate::pool::acquire_postgres_migration_connection(pool).await?; - - if POSTGRES_MIGRATOR.locking { - conn.lock().await?; - } - - let result = prepare_database_for_startup_locked(&mut conn, bootstrap).await; - - if POSTGRES_MIGRATOR.locking { - match conn.unlock().await { - Ok(()) => {} - Err(unlock_error) if result.is_ok() => return Err(unlock_error), - Err(unlock_error) => { - warn!( - error = %unlock_error, - "database migration lock release failed after startup preparation error" - ); - } + let mut abort_guard = MigrationAbortGuard::new(pool, &mut conn).await?; + let result = with_deadline(timeouts.transaction_ms, None, async { + timeouts.apply(&mut conn, false).await?; + if POSTGRES_MIGRATOR.locking { + conn.lock().await?; } + let pending = prepare_database_for_startup_locked(&mut conn, bootstrap).await?; + if POSTGRES_MIGRATOR.locking { + conn.unlock().await?; + } + Ok(pending) + }) + .await; + if result.is_err() { + abort_guard.abort().await; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + conn.detach().close_hard(), + ) + .await; + } else { + abort_guard.disarm(); } - result } async fn run_migrations_locked( conn: &mut PgConnection, - bootstrap: &dyn PostgresMigrationBootstrap, + timeouts: MigrationTimeouts, ) -> Result<(), MigrateError> { - conn.ensure_migrations_table().await?; - bootstrap.apply_snapshot(conn, &POSTGRES_MIGRATOR).await?; - + // Snapshot SQL and legacy migrations may change session settings. Restore + // the limits before inspecting or applying the next migration. + timeouts.apply(conn, false).await?; if let Some(version) = conn.dirty_version().await? { error!(version, "database migration state is dirty"); return Err(MigrateError::Dirty(version)); @@ -208,10 +193,20 @@ async fn run_migrations_locked( total = pending_migrations.len(), version = migration.version, description = %migration.description, + lock_timeout_ms = timeouts.lock_ms, + migration_timeout_ms = timeouts.execution_ms(migration.no_tx), "applying database migration" ); - repair_invalid_concurrent_index(conn, migration.version).await?; - let elapsed = conn.apply(migration).await?; + let elapsed = with_deadline( + timeouts.execution_ms(migration.no_tx), + Some(migration.version), + async { + timeouts.apply(conn, migration.no_tx).await?; + repair_invalid_concurrent_index(conn, migration.version).await?; + conn.apply(migration).await + }, + ) + .await?; info!( current, total = pending_migrations.len(), @@ -234,26 +229,20 @@ async fn repair_invalid_concurrent_index( conn: &mut PgConnection, migration_version: i64, ) -> Result<(), MigrateError> { - let (index_name, invalid_index_exists_sql, drop_index_sql) = match migration_version { - USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_MIGRATION_VERSION => ( - "idx_usage_legacy_body_ref_cleanup_created_at", - INVALID_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_EXISTS_SQL, - DROP_USAGE_LEGACY_BODY_REF_CLEANUP_INDEX_SQL, - ), - USAGE_SETTLEMENT_DASHBOARD_INDEX_MIGRATION_VERSION => ( - "idx_usage_settlement_dashboard_cover", - INVALID_USAGE_SETTLEMENT_DASHBOARD_INDEX_EXISTS_SQL, - DROP_USAGE_SETTLEMENT_DASHBOARD_INDEX_SQL, - ), - USAGE_STALE_PENDING_CLEANUP_INDEX_MIGRATION_VERSION => ( - "idx_usage_stale_pending_created_request", - INVALID_USAGE_STALE_PENDING_CLEANUP_INDEX_EXISTS_SQL, - DROP_USAGE_STALE_PENDING_CLEANUP_INDEX_SQL, - ), + // Only these fixed, trusted identifiers may be interpolated into DROP INDEX. + let index_name = match migration_version { + 20260715000000 => "idx_usage_legacy_body_ref_cleanup_created_at", + 20260715130000 => "idx_usage_settlement_dashboard_cover", + 20260720000000 => "idx_usage_stale_pending_created_request", + 20260918000000 => "idx_usage_settlement_dashboard_cover_v2", + 20260920000000 => "idx_payment_orders_status_credited_user", + 20260921020000 => "ix_usage_attribution_owner_request", + 20260921020100 => "ix_usage_analytics_actor_metadata", _ => return Ok(()), }; - let invalid_index_exists: bool = query_scalar(invalid_index_exists_sql) + let invalid_index_exists: bool = query_scalar(INVALID_CONCURRENT_INDEX_EXISTS_SQL) + .bind(index_name) .fetch_one(&mut *conn) .await?; if !invalid_index_exists { @@ -265,7 +254,11 @@ async fn repair_invalid_concurrent_index( index = index_name, "dropping invalid index left by an interrupted concurrent migration" ); - query(drop_index_sql).execute(&mut *conn).await?; + query(&format!( + "DROP INDEX CONCURRENTLY IF EXISTS public.{index_name}" + )) + .execute(&mut *conn) + .await?; Ok(()) } @@ -369,7 +362,44 @@ fn validate_applied_migrations( #[cfg(test)] mod tests { - use super::{all_up_migrations, pending_migrations_from_applied, POSTGRES_MIGRATOR}; + use super::{ + all_up_migrations, pending_migrations_from_applied, validate_applied_migrations, + POSTGRES_MIGRATOR, + }; + + #[test] + fn historical_account_attribution_migration_remains_valid() { + let checksum = "16210b169c8fc1e428de0336b836170652014a39a53e966bc3a25d5080c5b7e2532c7aed085b7e186c4c9a0d7ea0ea2f"; + let historical = sqlx::migrate::AppliedMigration { + version: 20260917000000, + checksum: (0..checksum.len()) + .step_by(2) + .map(|offset| u8::from_str_radix(&checksum[offset..offset + 2], 16).unwrap()) + .collect::>() + .into(), + }; + validate_applied_migrations(std::slice::from_ref(&historical)).unwrap(); + let embedded = POSTGRES_MIGRATOR + .iter() + .find(|migration| migration.version == historical.version) + .unwrap(); + assert_eq!(embedded.checksum, historical.checksum); + assert!(!pending_migrations_from_applied(&[historical]) + .iter() + .any(|migration| migration.version == 20260917000000)); + } + + #[test] + fn unknown_applied_migrations_still_block_startup() { + let unknown = sqlx::migrate::AppliedMigration { + version: 20990101000000, + checksum: Vec::new().into(), + }; + assert!(matches!( + validate_applied_migrations(&[unknown]), + Err(sqlx::migrate::MigrateError::VersionMissing(20990101000000)) + )); + } #[test] fn embeds_ordered_postgres_migration_sources() { @@ -382,6 +412,21 @@ mod tests { assert_eq!(pending_migrations_from_applied(&[]), all_up_migrations()); } + #[test] + fn pending_migrations_preserve_gaps_and_do_not_repeat_completed_index_builds() { + let applied = POSTGRES_MIGRATOR + .iter() + .filter(|migration| migration.version != 20260918000000) + .map(|migration| sqlx::migrate::AppliedMigration { + version: migration.version, + checksum: migration.checksum.clone(), + }) + .collect::>(); + let pending = pending_migrations_from_applied(&applied); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].version, 20260918000000); + } + #[test] fn embeds_scoped_codex_live_permission_migration() { let migration = POSTGRES_MIGRATOR @@ -408,7 +453,16 @@ mod tests { #[test] fn concurrent_index_migrations_opt_out_of_transactions() { - for version in [20260715000000, 20260715130000, 20260720000000] { + for version in [ + 20260715000000, + 20260715130000, + 20260720000000, + 20260918000000, + 20260918000100, + 20260920000000, + 20260921020000, + 20260921020100, + ] { let migration = POSTGRES_MIGRATOR .iter() .find(|migration| migration.version == version) diff --git a/crates/aether-data/adapters/postgres/src/migrations/cancellation.rs b/crates/aether-data/adapters/postgres/src/migrations/cancellation.rs new file mode 100644 index 000000000..310f27525 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/migrations/cancellation.rs @@ -0,0 +1,98 @@ +use std::time::Duration; + +use sqlx::{migrate::MigrateError, Connection, PgConnection, PgPool}; +use tokio::task::JoinHandle; +use tracing::warn; + +use super::timeouts::with_deadline; + +// A client-side timeout does not cancel PostgreSQL's running statement. Use a +// separate connection to terminate only our own migration session, including +// when the caller drops the migration future. Never borrow from the pool here: +// it can have a single connection, currently held by the migration itself. +pub(super) struct MigrationAbortGuard { + control: Option, + backend: Option<(i32, String)>, +} + +impl MigrationAbortGuard { + pub async fn new(pool: &PgPool, conn: &mut PgConnection) -> Result { + let (backend, control) = with_deadline(3_000, None, async { + let backend = sqlx::query_as::<_, (i32, String)>( + "SELECT pid, backend_start::text FROM pg_stat_activity WHERE pid = pg_backend_pid()", + ) + .fetch_one(conn) + .await?; + // Reserve the cancellation connection before taking any migration + // locks. A full server must fail preparation, not prevent cleanup. + let control = PgConnection::connect_with(pool.connect_options().as_ref()).await?; + Ok((backend, control)) + }) + .await?; + Ok(Self { + control: Some(control), + backend: Some(backend), + }) + } + + pub fn disarm(&mut self) { + self.backend = None; + self.control = None; + } + + pub async fn abort(&mut self) { + if let Some(task) = self.start_abort() { + // Dropping this await leaves the cleanup task running. + let _ = task.await; + } + } + + fn start_abort(&mut self) -> Option> { + let (pid, started_at) = self.backend.take()?; + let mut control = self.control.take()?; + let runtime = match tokio::runtime::Handle::try_current() { + Ok(runtime) => runtime, + Err(error) => { + warn!(pid, %error, "runtime unavailable to terminate migration session"); + return None; + } + }; + Some(runtime.spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(3), async { + // backend_start guards PID reuse. Same role + same database + // guards against ever signalling an unrelated database user. + // The second argument waits at most one second for termination. + let terminated = sqlx::query_scalar::<_, bool>( + "SELECT pg_terminate_backend(pid, 1000) FROM pg_stat_activity \ + WHERE pid = $1 AND backend_start = $2::text::timestamptz \ + AND usename = current_user AND datname = current_database()", + ) + .bind(pid) + .bind(started_at) + .fetch_optional(&mut control) + .await?; + if terminated == Some(false) { + warn!( + pid, + "migration session did not terminate within the cleanup deadline" + ); + } + control.close_hard().await + }) + .await; + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => warn!(pid, %error, "failed to terminate migration session"), + Err(error) => warn!(pid, %error, "migration session cleanup timed out"), + } + })) + } +} + +impl Drop for MigrationAbortGuard { + fn drop(&mut self) { + // SQLx close_on_drop still discards the data connection; the independent + // task makes cancellation release server-side DDL locks promptly too. + let _ = self.start_abort(); + } +} diff --git a/crates/aether-data/adapters/postgres/src/migrations/timeouts.rs b/crates/aether-data/adapters/postgres/src/migrations/timeouts.rs new file mode 100644 index 000000000..29914f2a7 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/migrations/timeouts.rs @@ -0,0 +1,110 @@ +use std::{future::Future, io, time::Duration}; + +use sqlx::{migrate::MigrateError, PgConnection}; + +const LOCK_TIMEOUT_ENV: &str = "AETHER_POSTGRES_MIGRATION_LOCK_TIMEOUT_MS"; +const TRANSACTION_TIMEOUT_ENV: &str = "AETHER_POSTGRES_MIGRATION_TIMEOUT_MS"; +const CONCURRENT_TIMEOUT_ENV: &str = "AETHER_POSTGRES_MIGRATION_CONCURRENT_TIMEOUT_MS"; + +#[derive(Clone, Copy)] +pub(super) struct MigrationTimeouts { + pub lock_ms: u32, + pub transaction_ms: u32, + concurrent_ms: u32, +} + +impl MigrationTimeouts { + pub fn from_env() -> Result { + Ok(Self { + lock_ms: read_timeout(LOCK_TIMEOUT_ENV, 1_000)?, + transaction_ms: read_timeout(TRANSACTION_TIMEOUT_ENV, 10_000)?, + concurrent_ms: read_timeout(CONCURRENT_TIMEOUT_ENV, 900_000)?, + }) + } + + pub fn execution_ms(self, no_transaction: bool) -> u32 { + if no_transaction { + self.concurrent_ms + } else { + self.transaction_ms + } + } + + pub async fn apply( + self, + conn: &mut PgConnection, + no_transaction: bool, + ) -> Result<(), MigrateError> { + sqlx::query( + "SELECT set_config('lock_timeout', $1, false), \ + set_config('statement_timeout', $2, false)", + ) + .bind(format!("{}ms", self.lock_ms)) + .bind(format!("{}ms", self.execution_ms(no_transaction))) + .execute(conn) + .await?; + Ok(()) + } +} + +fn read_timeout(name: &str, default: u32) -> Result { + match std::env::var(name) { + Ok(value) => parse_timeout(name, &value), + Err(std::env::VarError::NotPresent) => Ok(default), + Err(error) => Err(MigrateError::Source(Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name}: {error}"), + )))), + } +} + +fn parse_timeout(name: &str, value: &str) -> Result { + value + .trim() + .parse::() + .ok() + .filter(|value| (1..=i32::MAX as u32).contains(value)) + .ok_or_else(|| { + MigrateError::Source(Box::new(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} must be milliseconds in 1..=2147483647; migration timeouts cannot be disabled"), + ))) + }) +} + +pub(super) async fn with_deadline( + milliseconds: u32, + version: Option, + operation: impl Future>, +) -> Result { + match tokio::time::timeout(Duration::from_millis(u64::from(milliseconds)), operation).await { + Ok(result) => result, + Err(_) => { + let error = sqlx::Error::Io(io::Error::new( + io::ErrorKind::TimedOut, + format!("database migration exceeded {milliseconds}ms; connection will be closed; retry during a quieter period"), + )); + Err(match version { + Some(version) => MigrateError::ExecuteMigration(error, version), + None => MigrateError::Execute(error), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::parse_timeout; + + #[test] + fn migration_limits_cannot_be_disabled_or_overflow_postgres() { + for invalid in ["0", "-1", "2147483648", "500ms", "", " "] { + assert!(parse_timeout("TEST_TIMEOUT", invalid).is_err(), "{invalid}"); + } + assert_eq!(parse_timeout("TEST_TIMEOUT", " 1000 ").unwrap(), 1_000); + assert_eq!( + parse_timeout("TEST_TIMEOUT", "2147483647").unwrap(), + i32::MAX as u32 + ); + } +} diff --git a/crates/aether-data/adapters/postgres/src/settlement.rs b/crates/aether-data/adapters/postgres/src/settlement.rs index fba7d0bd5..a03c2271b 100644 --- a/crates/aether-data/adapters/postgres/src/settlement.rs +++ b/crates/aether-data/adapters/postgres/src/settlement.rs @@ -635,7 +635,7 @@ WHERE user_entitlement_id = $1 let mut remaining_cost = total_cost_usd; let mut debited = 0.0; for (grant, balance_before) in grants_with_remaining { - if remaining_cost <= 0.000_000_01 || balance_before <= 0.0 { + if remaining_cost <= 0.0 || balance_before <= 0.0 { continue; } let amount = remaining_cost.min(balance_before); @@ -1163,6 +1163,12 @@ WHERE retain_until <= TO_TIMESTAMP($1::double precision) provider_monthly_used_usd: None, finalized_at_unix_secs: Some(finalized_at as u64), }; + let mut quota_covered = 0.0_f64; + let mut wallet_consumed = 0.0_f64; + let mut wallet_debit = 0.0_f64; + let mut recharge_debit = 0.0_f64; + let mut gift_debit = 0.0_f64; + let mut overdraft = 0.0_f64; if final_billing_status == "settled" { let api_key_id = input @@ -1305,6 +1311,7 @@ LIMIT 1 settlement.billing_status = final_billing_status.clone(); 0.0 } else { + quota_covered = quota.debited_usd; (billable_cost_usd - quota.debited_usd).max(0.0) } } else { @@ -1325,7 +1332,7 @@ LIMIT 1 return Ok(Some(settlement)); } - if wallet_debit_cost_usd > SETTLEMENT_EPSILON_USD { + if wallet_debit_cost_usd > 0.0 { if let Some(wallet_row) = wallet_row { let wallet_id: String = wallet_row.try_get("id").map_postgres_err()?; @@ -1346,10 +1353,15 @@ LIMIT 1 before_gift, wallet_debit_cost_usd, ); + recharge_debit = debit_plan.recharge_deduction; + gift_debit = debit_plan.gift_deduction; + overdraft = debit_plan.recharge_overdraft; + wallet_debit = recharge_debit + gift_debit + overdraft; (after_recharge, after_gift) = debit_plan.after_balances(before_recharge, before_gift); } let total_consumed_after = total_consumed + wallet_debit_cost_usd; + wallet_consumed = wallet_debit_cost_usd; validate_wallet_settlement_values( after_recharge, after_gift, @@ -1418,6 +1430,29 @@ WHERE id = $1 } sync_usage_settlement_snapshot(&mut **tx, &settlement).await?; + if final_billing_status == "settled" { + sqlx::query( + r#"UPDATE usage_settlement_snapshots SET + quota_covered_amount_usd = $2::text::numeric(20,8), + wallet_consumed_amount_usd = $3::text::numeric(20,8), + wallet_debit_amount_usd = $4::text::numeric(20,8), + wallet_recharge_debit_usd = $5::text::numeric(20,8), + wallet_gift_debit_usd = $6::text::numeric(20,8), + wallet_overdraft_usd = $7::text::numeric(20,8), + allocation_schema_version = 1, allocation_status = 'complete' + WHERE request_id = $1"#, + ) + .bind(&input.request_id) + .bind(format!("{quota_covered:.8}")) + .bind(format!("{wallet_consumed:.8}")) + .bind(format!("{wallet_debit:.8}")) + .bind(format!("{recharge_debit:.8}")) + .bind(format!("{gift_debit:.8}")) + .bind(format!("{overdraft:.8}")) + .execute(&mut **tx) + .await + .map_postgres_err()?; + } sqlx::query(FINALIZE_USAGE_BILLING_SQL) .bind(&input.request_id) .bind(&final_billing_status) diff --git a/crates/aether-data/adapters/postgres/src/usage/analytics.rs b/crates/aether-data/adapters/postgres/src/usage/analytics.rs new file mode 100644 index 000000000..2c4080500 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/analytics.rs @@ -0,0 +1,603 @@ +use super::SqlxUsageReadRepository; +use crate::error::SqlxResultExt; +use aether_data_contracts::repository::usage::*; +use aether_data_contracts::DataLayerError; +use chrono::{DateTime, Utc}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use sqlx::{Postgres, QueryBuilder, Row}; + +pub(super) fn analytics_metrics_sql(slow_threshold_ms: u64) -> String { + format!( + "{},{}", + analytics_additive_metrics_sql(slow_threshold_ms), + ANALYTICS_NON_ADDITIVE_METRICS_SQL + ) +} + +const ANALYTICS_NON_ADDITIVE_METRICS_SQL: &str = r#" +count(DISTINCT actor_user_id)::bigint AS usage_active_users, +percentile_cont(0.5) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p50_ms, +percentile_cont(0.90) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p90_ms, +percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p95_ms, +percentile_cont(0.99) WITHIN GROUP (ORDER BY response_time_ms) AS latency_p99_ms, +percentile_cont(0.90) WITHIN GROUP (ORDER BY first_byte_time_ms) AS first_byte_p90_ms, +percentile_cont(0.99) WITHIN GROUP (ORDER BY first_byte_time_ms) AS first_byte_p99_ms +"#; + +// Full analytics also describe attribution and token provenance. Lifetime cards +// only need usage, pricing, settlement and allocation coverage below. +const ANALYTICS_COVERAGE_METRICS_SQL: &str = r#" +count(*) FILTER (WHERE usage_available)::bigint AS usage_available_count, +count(*) FILTER (WHERE usage_available AND token_source='reported')::bigint AS reported_usage_count, +count(*) FILTER (WHERE usage_available AND token_source='estimated')::bigint AS estimated_usage_count, +count(*) FILTER (WHERE usage_available AND token_source='mixed')::bigint AS mixed_usage_count, +count(*) FILTER (WHERE NOT usage_available OR token_source='unknown')::bigint AS unknown_usage_count, +count(*) FILTER (WHERE pricing_available)::bigint AS pricing_available_count, +count(*) FILTER (WHERE settlement_status = 'settled')::bigint AS settled_count, +count(*) FILTER (WHERE allocation_status = 'complete')::bigint AS allocation_available_count, +count(*) FILTER (WHERE actor_user_id IS NOT NULL)::bigint AS trusted_attribution_count, +count(*) FILTER (WHERE status = 'failed' AND failure_origin IS NOT NULL AND failure_origin <> 'unknown')::bigint AS classified_failure_count +"#; + +pub(super) fn dashboard_total_metrics_sql() -> &'static str { + r#"count(*)::bigint AS request_count, +COALESCE(sum(total_tokens),0)::bigint AS total_tokens, +sum(billable_amount)::text AS billable_amount, +count(*) FILTER (WHERE usage_available)::bigint AS usage_available_count, +count(*) FILTER (WHERE pricing_available)::bigint AS pricing_available_count, +count(*) FILTER (WHERE settlement_status='settled')::bigint AS settled_count, +count(*) FILTER (WHERE allocation_status='complete')::bigint AS allocation_available_count"# +} + +pub(super) fn analytics_additive_metrics_sql(slow_threshold_ms: u64) -> String { + format!( + r#" +count(*)::bigint AS request_count, +count(*) FILTER (WHERE status = 'completed')::bigint AS successful_request_count, +count(*) FILTER (WHERE status = 'failed')::bigint AS failed_request_count, +count(*) FILTER (WHERE status = 'cancelled')::bigint AS cancelled_request_count, +count(*) FILTER (WHERE status NOT IN ('completed','failed','cancelled'))::bigint AS in_flight_request_count, +COALESCE(sum(input_tokens),0)::bigint AS input_tokens, +COALESCE(sum(output_tokens),0)::bigint AS output_tokens, +COALESCE(sum(total_tokens),0)::bigint AS total_tokens, +COALESCE(sum(cache_read_input_tokens),0)::bigint AS cache_read_input_tokens, +COALESCE(sum(cache_creation_input_tokens),0)::bigint AS cache_creation_input_tokens, +count(cache_estimated_full_cost_amount)::bigint AS cache_pricing_available_count, +sum(cache_read_cost_amount)::text AS cache_read_cost_amount, +sum(cache_creation_cost_amount)::text AS cache_creation_cost_amount, +sum(cache_estimated_full_cost_amount)::text AS cache_estimated_full_cost_amount, +{ANALYTICS_COVERAGE_METRICS_SQL}, +count(response_time_ms)::bigint AS latency_sample_count, +count(*) FILTER (WHERE response_time_ms >= {slow_threshold_ms})::bigint AS slow_request_count, +COALESCE(sum(response_time_ms),0)::double precision AS latency_sum_ms, +count(first_byte_time_ms)::bigint AS first_byte_sample_count, +COALESCE(sum(first_byte_time_ms),0)::double precision AS first_byte_sum_ms, +count(*) FILTER (WHERE upstream_is_stream AND output_tokens > 0 AND response_time_ms > first_byte_time_ms)::bigint AS output_tps_sample_count, +COALESCE(sum(output_tokens::double precision * 1000 / NULLIF(response_time_ms - first_byte_time_ms, 0)) FILTER (WHERE upstream_is_stream AND output_tokens > 0 AND response_time_ms > first_byte_time_ms),0)::double precision AS output_tps_sum, +sum(rated_amount)::text AS rated_amount, +sum(billable_amount)::text AS billable_amount, +sum(quota_covered_amount)::text AS quota_covered_amount, +sum(wallet_consumed_amount)::text AS wallet_consumed_amount, +sum(wallet_debit_amount)::text AS wallet_debit_amount, +sum(wallet_recharge_debit_amount)::text AS wallet_recharge_debit_amount, +sum(wallet_gift_debit_amount)::text AS wallet_gift_debit_amount, +sum(wallet_overdraft_amount)::text AS wallet_overdraft_amount +"# + ) +} + +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(|error| { + DataLayerError::UnexpectedValue(format!("invalid analytics query result: {error}")) + }) +} + +pub(super) fn push_analytics_filter( + builder: &mut QueryBuilder<'_, Postgres>, + query: &UsageAnalyticsQuery, +) { + push_analytics_source_filter(builder, query, "public.usage_analytics_facts_v1"); +} + +pub(super) fn push_analytics_source_filter( + builder: &mut QueryBuilder<'_, Postgres>, + query: &UsageAnalyticsQuery, + source: &str, +) { + builder + .push(" FROM ") + .push(source) + .push(" WHERE created_at >= ") + .push_bind( + DateTime::::from_timestamp_millis(query.from_unix_ms as i64) + .expect("validated timestamp"), + ) + .push(" AND created_at < ") + .push_bind( + DateTime::::from_timestamp_millis(query.to_unix_ms as i64) + .expect("validated timestamp"), + ) + .push(" AND record_kind <> 'session'"); + for (column, value) in [ + ("actor_user_id", &query.actor_user_id), + ("credential_owner_id", &query.credential_owner_id), + ("attribution_kind", &query.attribution_kind), + ("api_key_id", &query.api_key_id), + ("model", &query.model), + ("provider_id", &query.provider_id), + ("api_format", &query.api_format), + ("endpoint_kind", &query.endpoint_kind), + ("request_type", &query.request_type), + ("status", &query.status), + ] { + if let Some(value) = value { + builder + .push(" AND ") + .push(column) + .push(" = ") + .push_bind(value.clone()); + } + } + for (column, value) in [ + ("is_stream", query.is_stream), + ("has_format_conversion", query.has_format_conversion), + ] { + if let Some(value) = value { + builder + .push(" AND ") + .push(column) + .push(" = ") + .push_bind(value); + } + } +} + +fn push_user_filter(builder: &mut QueryBuilder<'_, Postgres>, query: &UsageAnalyticsQuery) { + builder.push(" WHERE NOT u.is_deleted"); + if let Some(user_id) = query + .actor_user_id + .as_ref() + .or(query.credential_owner_id.as_ref()) + { + builder.push(" AND u.id = ").push_bind(user_id.clone()); + } + if let Some(value) = query.user_is_active { + builder.push(" AND u.is_active = ").push_bind(value); + } + if let Some(search) = query.search.as_ref().filter(|value| !value.is_empty()) { + let pattern = format!( + "%{}%", + search + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") + ); + builder + .push(" AND (u.username ILIKE ") + .push_bind(pattern.clone()) + .push(" OR u.email ILIKE ") + .push_bind(pattern) + .push(")"); + } + if let Some(value) = query.has_usage { + builder.push(if value { + " AND a.user_id IS NOT NULL" + } else { + " AND a.user_id IS NULL" + }); + } +} + +fn push_user_finance_ctes(builder: &mut QueryBuilder<'_, Postgres>, query: &UsageAnalyticsQuery) { + // Aggregate each financial source before joining the roster. Joining orders + // directly to request facts would multiply both usage and payment amounts. + builder.push(r#", wallet_finance AS ( + SELECT w.user_id, + CASE WHEN bool_and(w.currency = 'USD') THEN round(sum(w.balance::numeric + w.gift_balance::numeric), 8)::text END AS wallet_balance, + CASE WHEN bool_and(w.currency = 'USD') THEN round(sum(w.balance::numeric), 8)::text END AS recharge_balance, + CASE WHEN bool_and(w.currency = 'USD') THEN round(sum(w.gift_balance::numeric), 8)::text END AS gift_balance + FROM wallets w JOIN roster r ON r.user_id = w.user_id GROUP BY w.user_id + ), credited_orders AS MATERIALIZED ( + SELECT o.id, o.user_id, o.order_no, o.amount_usd, o.payment_method, o.credited_at, + CASE WHEN o.payment_method IN ('gift_code', 'admin_grant') THEN 'gift_credit' + WHEN o.order_kind = 'plan_purchase' THEN 'plan_purchase' + ELSE 'wallet_recharge' END AS kind + FROM payment_orders o JOIN roster r ON r.user_id = o.user_id + WHERE o.status = 'credited' AND o.credited_at IS NOT NULL + AND o.order_kind IN ('wallet_recharge', 'plan_purchase') AND o.credited_at >= "#) + .push_bind(DateTime::::from_timestamp_millis(query.from_unix_ms as i64).expect("validated")) + .push(" AND o.credited_at < ") + .push_bind(DateTime::::from_timestamp_millis(query.to_unix_ms as i64).expect("validated")) + .push(r#"), payment_finance AS ( + SELECT user_id, + round(COALESCE(sum(amount_usd::numeric) FILTER (WHERE kind = 'wallet_recharge'), 0), 8)::text AS recharge_amount, + count(*) FILTER (WHERE kind = 'wallet_recharge')::bigint AS recharge_count, + round(COALESCE(sum(amount_usd::numeric) FILTER (WHERE kind = 'plan_purchase'), 0), 8)::text AS plan_purchase_amount, + count(*) FILTER (WHERE kind = 'plan_purchase')::bigint AS plan_purchase_count, + round(COALESCE(sum(amount_usd::numeric) FILTER (WHERE kind = 'gift_credit'), 0), 8)::text AS gift_credit_amount, + count(*) FILTER (WHERE kind = 'gift_credit')::bigint AS gift_credit_count + FROM credited_orders GROUP BY user_id + ), enriched AS ( + SELECT r.*, jsonb_build_object( + 'wallet_balance', CASE WHEN w.user_id IS NULL THEN '0.00000000' ELSE w.wallet_balance END, + 'recharge_balance', CASE WHEN w.user_id IS NULL THEN '0.00000000' ELSE w.recharge_balance END, + 'gift_balance', CASE WHEN w.user_id IS NULL THEN '0.00000000' ELSE w.gift_balance END, + 'recharge_amount', COALESCE(p.recharge_amount, '0.00000000'), + 'recharge_count', COALESCE(p.recharge_count, 0), + 'plan_purchase_amount', COALESCE(p.plan_purchase_amount, '0.00000000'), + 'plan_purchase_count', COALESCE(p.plan_purchase_count, 0), + 'gift_credit_amount', COALESCE(p.gift_credit_amount, '0.00000000'), + 'gift_credit_count', COALESCE(p.gift_credit_count, 0) + ) AS finance + FROM roster r LEFT JOIN wallet_finance w ON w.user_id = r.user_id + LEFT JOIN payment_finance p ON p.user_id = r.user_id + )"#); +} + +fn user_finance_summary_sql() -> String { + let mut fields = Vec::new(); + for field in [ + "wallet_balance", + "recharge_balance", + "gift_balance", + "recharge_amount", + "plan_purchase_amount", + "gift_credit_amount", + ] { + // If a wallet has an unsupported currency, never disguise the known USD + // subtotal as a complete balance. An empty supported roster is zero. + fields.push(format!("'{field}', CASE WHEN count(*) FILTER (WHERE finance->>'{field}' IS NULL) = 0 THEN round(COALESCE(sum((finance->>'{field}')::numeric), 0), 8)::text END")); + } + for field in ["recharge_count", "plan_purchase_count", "gift_credit_count"] { + fields.push(format!( + "'{field}', COALESCE(sum((finance->>'{field}')::bigint), 0)::bigint" + )); + } + format!("jsonb_build_object({})", fields.join(", ")) +} + +pub(super) async fn read_analytics_metrics( + tx: &mut sqlx::Transaction<'_, Postgres>, + query: &UsageAnalyticsQuery, + projection_reads: bool, +) -> Result { + let metrics_sql = analytics_metrics_sql(query.slow_threshold_ms.unwrap_or(5000)); + let projected = projection_reads && super::projection_reader::supports_projection(query); + let mut builder = QueryBuilder::::new("SELECT to_jsonb(m) AS metrics FROM (SELECT "); + builder.push(if projected { + ANALYTICS_NON_ADDITIVE_METRICS_SQL + } else { + &metrics_sql + }); + push_analytics_filter(&mut builder, query); + builder.push(") m"); + let row = builder + .build() + .fetch_one(&mut **tx) + .await + .map_postgres_err()?; + let mut summary: UsageAnalyticsMetrics = decode(row.try_get("metrics").map_postgres_err()?)?; + if projected { + let mut additive = super::projection_reader::read_additive_summary(tx, query).await?; + additive.usage_active_users = summary.usage_active_users; + additive.latency_p50_ms = summary.latency_p50_ms; + additive.latency_p90_ms = summary.latency_p90_ms; + additive.latency_p95_ms = summary.latency_p95_ms; + additive.latency_p99_ms = summary.latency_p99_ms; + additive.first_byte_p90_ms = summary.first_byte_p90_ms; + additive.first_byte_p99_ms = summary.first_byte_p99_ms; + summary = additive; + } + Ok(summary) +} + +impl SqlxUsageReadRepository { + pub async fn query_usage_analytics( + &self, + query: &UsageAnalyticsQuery, + ) -> Result { + query.validate()?; + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout = '15s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let state = sqlx::query("SELECT pg_current_snapshot()::text AS revision, NOW() AS generated_at, (SELECT count(*) FROM users WHERE is_active AND NOT is_deleted) AS enabled_users") + .fetch_one(&mut *tx).await.map_postgres_err()?; + let metrics_sql = analytics_metrics_sql(query.slow_threshold_ms.unwrap_or(5000)); + // The users branch computes a summary over its full filtered roster in + // the same statement as the page. An installation-wide summary here + // would be discarded and unnecessarily scan request facts again. + let mut summary = if query.view == UsageAnalyticsView::Users { + UsageAnalyticsMetrics::default() + } else { + read_analytics_metrics(&mut tx, query, self.overview_projection_reads).await? + }; + summary.enabled_users = state + .try_get::("enabled_users") + .map_postgres_err()? as u64; + let mut result = StoredUsageAnalytics { + total: summary.request_count, + summary, + read_revision: state.try_get("revision").map_postgres_err()?, + generated_at: state + .try_get::, _>("generated_at") + .map_postgres_err()? + .to_rfc3339(), + // Source freshness is unknown until a collector watermark is available. + data_through: None, + ..Default::default() + }; + result.unrecoverable_bucket_count = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT bucket_start) FROM (SELECT bucket_start FROM stats_bucket_state WHERE projection_version IN ('overview-v1','overview-v2') AND granularity='hour' AND coverage_status='unrecoverable' UNION ALL SELECT bucket_start FROM stats_overview_dirty_events WHERE projection_version='overview-v2' AND granularity='hour' AND unrecoverable) lost WHERE bucket_start < $2 AND bucket_start + INTERVAL '1 hour' > $1") + .bind(DateTime::::from_timestamp_millis(query.from_unix_ms as i64).expect("validated")) + .bind(DateTime::::from_timestamp_millis(query.to_unix_ms as i64).expect("validated")) + .fetch_one(&mut *tx).await.map_postgres_err()? as u64; + result.coverage = super::projection_reader::read_projection_coverage( + &mut tx, + query, + self.overview_projection_reads, + ) + .await?; + if query.view != UsageAnalyticsView::Summary { + let mut builder = + QueryBuilder::::new("WITH filtered AS MATERIALIZED (SELECT *"); + push_analytics_filter(&mut builder, query); + builder.push(")"); + let order = if query.descending { + " DESC NULLS LAST" + } else { + " ASC NULLS LAST" + }; + match query.view { + UsageAnalyticsView::Timeseries + | UsageAnalyticsView::Performance + | UsageAnalyticsView::DashboardCharts + | UsageAnalyticsView::Breakdown => { + let timeseries = query.view != UsageAnalyticsView::Breakdown; + let group = if timeseries { + let granularity = match query.granularity { + UsageAnalyticsGranularity::Hour => "hour", + UsageAnalyticsGranularity::Day => "day", + }; + // The IANA name was parsed above; it is still bound as a SQL value. + let timezone = if query.granularity == UsageAnalyticsGranularity::Hour { + "UTC".into() + } else { + query.timezone.clone() + }; + builder + .push(", dated AS (SELECT *, date_trunc('") + .push(granularity) + .push("', created_at AT TIME ZONE ") + .push_bind(timezone.clone()) + .push(") AT TIME ZONE ") + .push_bind(timezone) + .push(" AS bucket FROM filtered)"); + "bucket" + } else { + match query.group_by { + UsageAnalyticsGroupBy::Model => "model", + UsageAnalyticsGroupBy::Provider => "provider_id", + UsageAnalyticsGroupBy::ApiKey => "api_key_id", + UsageAnalyticsGroupBy::Attribution => "attribution_kind", + UsageAnalyticsGroupBy::ApiFormat => "api_format", + UsageAnalyticsGroupBy::RequestType => "request_type", + } + }; + builder + .push(", grouped AS (SELECT ") + .push(group) + .push(" AS group_id, ") + .push(&metrics_sql) + .push(if timeseries { + " FROM dated GROUP BY " + } else { + " FROM filtered GROUP BY " + }) + .push(group) + .push("), page AS (SELECT * FROM grouped ORDER BY "); + if timeseries { + builder.push("group_id ASC"); + } else { + builder + .push(match query.sort { + UsageAnalyticsSort::BillableAmount => "billable_amount::numeric", + UsageAnalyticsSort::Tokens => "total_tokens", + _ => "request_count", + }) + .push(order) + .push(", group_id ASC NULLS LAST"); + } + builder.push(" LIMIT ").push_bind(if timeseries { 10_001 } else { i64::from(query.limit) }).push(" OFFSET ").push_bind(if timeseries { 0 } else { query.offset as i64 }) + .push(") SELECT (SELECT count(*) FROM grouped) AS total, COALESCE(jsonb_agg(jsonb_build_object('id', group_id::text, 'label', group_id::text, 'bucket_start', ") + .push(if timeseries { "to_char(group_id AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')" } else { "NULL" }) + .push(", 'metrics', to_jsonb(page) - 'group_id')), '[]'::jsonb) AS items FROM page"); + let row = builder + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.total = row.try_get::("total").map_postgres_err()? as u64; + result.rows = decode(row.try_get("items").map_postgres_err()?)?; + } + UsageAnalyticsView::Users => { + let scoped_payments = + query.actor_user_id.is_some() || query.credential_owner_id.is_some(); + let subject = if query.actor_user_id.is_some() + || query.attribution_kind.as_deref() == Some("employee") + { + "actor_user_id" + } else { + "credential_owner_id" + }; + builder.push(", aggregated AS (SELECT ").push(subject).push(" AS user_id, max(created_at) AS last_used_at, count(DISTINCT (created_at AT TIME ZONE ") + .push_bind(query.timezone.clone()).push(")::date)::bigint AS active_days, ").push(&metrics_sql) + .push(" FROM filtered GROUP BY ").push(subject).push("), roster AS (SELECT u.id AS user_id, u.username, u.email, u.is_active, a.last_used_at,") + .push(" COALESCE(a.active_days, 0) AS active_days, COALESCE(to_jsonb(a) - 'user_id' - 'last_used_at' - 'active_days', '{}'::jsonb) AS metrics, COALESCE(a.request_count, 0) AS requests, a.billable_amount::numeric AS billable FROM users u LEFT JOIN aggregated a ON a.user_id = u.id"); + push_user_filter(&mut builder, query); + builder.push(")"); + push_user_finance_ctes(&mut builder, query); + builder.push(", page AS (SELECT * FROM enriched ORDER BY ") + .push(match query.sort { UsageAnalyticsSort::Requests => "requests", UsageAnalyticsSort::BillableAmount => "billable", UsageAnalyticsSort::LastUsed | UsageAnalyticsSort::StartedAt => "last_used_at", UsageAnalyticsSort::Username => "username", UsageAnalyticsSort::Tokens => "(metrics->>'total_tokens')::bigint", UsageAnalyticsSort::ActiveDays => "active_days" }) + .push(order).push(", user_id ASC LIMIT ").push_bind(i64::from(query.limit)).push(" OFFSET ").push_bind(query.offset as i64) + .push(") SELECT (SELECT count(*) FROM roster) AS total, (SELECT count(*) FROM roster WHERE requests > 0) AS active_user_count, (SELECT count(*) FROM roster WHERE is_active) AS enabled_user_count, ") + .push("(SELECT to_jsonb(m) FROM (SELECT ").push(&metrics_sql) + .push(" FROM filtered WHERE ").push(subject).push(" IN (SELECT user_id FROM roster)) m) AS summary, ") + .push("(SELECT ").push(user_finance_summary_sql()).push(" FROM enriched) AS finance_summary, ") + .push("(SELECT COALESCE(jsonb_agg(to_jsonb(p)), '[]'::jsonb) FROM (SELECT id, order_no, kind, round(amount_usd::numeric, 8)::text AS amount, payment_method, to_char(credited_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS credited_at FROM credited_orders ORDER BY credited_at DESC, id ASC LIMIT ") + .push_bind(if scoped_payments { i64::from(query.payment_limit.unwrap_or(10)) } else { 0 }) + .push(" OFFSET ").push_bind(query.payment_offset.unwrap_or(0) as i64) + .push(") p) AS payments, (SELECT count(*) FROM credited_orders) AS payment_total, ") + .push("COALESCE(jsonb_agg(to_jsonb(page) - 'requests' - 'billable'), '[]'::jsonb) AS items FROM page"); + let row = builder + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.total = row.try_get::("total").map_postgres_err()? as u64; + result.users = decode(row.try_get("items").map_postgres_err()?)?; + result.summary = decode(row.try_get("summary").map_postgres_err()?)?; + result.summary.enabled_users = + row.try_get::("enabled_user_count") + .map_postgres_err()? as u64; + result.user_summary = Some(UsageAnalyticsUserSummary { + user_count: result.total, + active_user_count: row + .try_get::("active_user_count") + .map_postgres_err()? as u64, + metrics: result.summary.clone(), + }); + result.user_finance_summary = + Some(decode(row.try_get("finance_summary").map_postgres_err()?)?); + if scoped_payments { + result.user_payments = Some(UsageAnalyticsUserPayments { + items: decode(row.try_get("payments").map_postgres_err()?)?, + total: row.try_get::("payment_total").map_postgres_err()? + as u64, + limit: query.payment_limit.unwrap_or(10), + offset: query.payment_offset.unwrap_or(0), + }); + } + } + UsageAnalyticsView::Consumption => { + builder.push(", page AS (SELECT id, request_id, created_at AS started_at, actor_user_id AS user_id, credential_owner_id, model, provider_name AS provider, provider_id, api_key_id, status, settlement_status, attribution_kind, attribution_source, rated_amount::text, billable_amount::text, quota_covered_amount::text, wallet_consumed_amount::text, wallet_debit_amount::text FROM filtered ORDER BY created_at ").push(order).push(", request_id ASC LIMIT ") + .push_bind(i64::from(query.limit)).push(" OFFSET ").push_bind(query.offset as i64) + .push(") SELECT COALESCE(jsonb_agg(to_jsonb(page)), '[]'::jsonb) AS items FROM page"); + let row = builder + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.consumption = decode(row.try_get("items").map_postgres_err()?)?; + } + UsageAnalyticsView::Summary => unreachable!(), + } + } + if matches!( + query.view, + UsageAnalyticsView::Timeseries + | UsageAnalyticsView::Performance + | UsageAnalyticsView::DashboardCharts + ) { + fill_usage_analytics_timeseries(query, &mut result.rows); + result.total = result.rows.len() as u64; + } + if matches!( + query.view, + UsageAnalyticsView::Performance | UsageAnalyticsView::DashboardCharts + ) { + let mut providers = QueryBuilder::::new("SELECT COALESCE(jsonb_agg(jsonb_build_object('id',provider_id,'label',provider_label,'bucket_start',NULL,'metrics',to_jsonb(m)-'provider_id'-'provider_label')), '[]'::jsonb) AS items FROM (SELECT provider_id, max(provider_name) AS provider_label, "); + providers.push(&metrics_sql); + push_analytics_filter(&mut providers, query); + providers.push(" GROUP BY provider_id ORDER BY count(*) DESC,provider_id"); + if query.view == UsageAnalyticsView::DashboardCharts { + providers.push(" LIMIT 10001"); + } + providers.push(") m"); + let row = providers + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.provider_rows = decode(row.try_get("items").map_postgres_err()?)?; + if result.provider_rows.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT + && query.view == UsageAnalyticsView::DashboardCharts + { + return Err(DataLayerError::InvalidInput( + "dashboard provider chart exceeds 10000 groups".into(), + )); + } + } + if query.view == UsageAnalyticsView::DashboardCharts { + let timezone = if query.granularity == UsageAnalyticsGranularity::Hour { + "UTC".into() + } else { + query.timezone.clone() + }; + let mut models = QueryBuilder::::new("WITH filtered AS (SELECT *"); + push_analytics_filter(&mut models, query); + models.push("), dated AS (SELECT *,date_trunc(") + .push_bind(if query.granularity==UsageAnalyticsGranularity::Hour {"hour"}else{"day"}) + .push(",created_at AT TIME ZONE ").push_bind(timezone.clone()) + .push(") AT TIME ZONE ").push_bind(timezone).push(" AS bucket FROM filtered) SELECT COALESCE(jsonb_agg(jsonb_build_object('id',model,'label',model,'bucket_start',to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'),'metrics',to_jsonb(m)-'model'-'bucket')),'[]'::jsonb) AS items FROM (SELECT model,bucket,") + .push(&metrics_sql).push(" FROM dated GROUP BY model,bucket ORDER BY bucket,model LIMIT 10001) m"); + let row = models + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.model_rows = decode(row.try_get("items").map_postgres_err()?)?; + if result.model_rows.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT { + return Err(DataLayerError::InvalidInput( + "dashboard model chart exceeds 10000 groups; narrow the range".into(), + )); + } + } + if query.view == UsageAnalyticsView::Performance { + // Aggregate requested models across providers in the same read snapshot. + // Like provider rows, retain raw samples for exact percentiles even when + // the summary combines verified hourly projections and raw gaps. + let mut models = QueryBuilder::::new("SELECT COALESCE(jsonb_agg(jsonb_build_object('id',model,'label',model,'bucket_start',NULL,'metrics',to_jsonb(m)-'model')), '[]'::jsonb) AS items FROM (SELECT model, "); + models.push(&metrics_sql); + push_analytics_filter(&mut models, query); + models.push(" GROUP BY model ORDER BY count(*) DESC,model NULLS LAST) m"); + let row = models + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.model_rows = decode(row.try_get("items").map_postgres_err()?)?; + + let mut timeline = QueryBuilder::::new("WITH filtered AS (SELECT *"); + push_analytics_filter(&mut timeline, query); + timeline.push("), dated AS (SELECT *, date_trunc(") + .push_bind(if query.granularity == UsageAnalyticsGranularity::Hour { "hour" } else { "day" }) + .push(", created_at AT TIME ZONE ").push_bind(if query.granularity == UsageAnalyticsGranularity::Hour { "UTC".into() } else { query.timezone.clone() }) + .push(") AT TIME ZONE ").push_bind(if query.granularity == UsageAnalyticsGranularity::Hour { "UTC".into() } else { query.timezone.clone() }) + .push(" AS bucket FROM filtered) SELECT COALESCE(jsonb_agg(jsonb_build_object('id',provider_id,'label',provider_label,'bucket_start',to_char(bucket AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'),'metrics',to_jsonb(m)-'provider_id'-'provider_label'-'bucket')),'[]'::jsonb) AS items FROM (SELECT provider_id,max(provider_name) AS provider_label,bucket,") + .push(&metrics_sql).push(" FROM dated GROUP BY provider_id,bucket ORDER BY bucket,provider_id) m"); + let row = timeline + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.provider_timeline_rows = decode(row.try_get("items").map_postgres_err()?)?; + let mut errors = QueryBuilder::::new("SELECT COALESCE(jsonb_agg(to_jsonb(m)), '[]'::jsonb) AS items FROM (SELECT COALESCE(failure_reason,error_category,'unknown') AS reason,count(*)::bigint AS count"); + push_analytics_filter(&mut errors, query); + errors.push(" AND status='failed' GROUP BY COALESCE(failure_reason,error_category,'unknown') ORDER BY count(*) DESC) m"); + let row = errors + .build() + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + result.errors = decode(row.try_get("items").map_postgres_err()?)?; + } + tx.commit().await.map_postgres_err()?; + Ok(result) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/analytics_tests.rs b/crates/aether-data/adapters/postgres/src/usage/analytics_tests.rs new file mode 100644 index 000000000..dc8a4acca --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/analytics_tests.rs @@ -0,0 +1,1334 @@ +use super::SqlxUsageReadRepository; +use aether_data_contracts::repository::usage::*; +use chrono::{TimeZone, Utc}; +use sqlx::Row; + +#[tokio::test] +#[ignore = "requires local AETHER_TEST_DATABASE_URL with temporary database creation"] +async fn live_overview_user_finance_uses_credited_period_and_full_roster() { + use futures_util::FutureExt; + use std::panic::AssertUnwindSafe; + let options = std::env::var("AETHER_TEST_DATABASE_URL") + .unwrap() + .parse::() + .unwrap(); + let admin = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options.clone()) + .await + .unwrap(); + let database = format!("overview_finance_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(&format!("CREATE DATABASE {database}")) + .execute(&admin) + .await + .unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect_with(options.database(&database)) + .await + .unwrap(); + let outcome = AssertUnwindSafe(async { + crate::POSTGRES_MIGRATOR.run(&pool).await.unwrap(); + let at = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(); + for (id, active, deleted, balance, gift) in [ + ("alice", true, false, 12.0, 3.0), + ("bob", false, false, 5.0, 1.0), + ("deleted", false, true, 999.0, 999.0), + ] { + sqlx::query("INSERT INTO users(id,username,email_verified,is_active,is_deleted) VALUES($1,$1,false,$2,$3)") + .bind(id).bind(active).bind(deleted).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO wallets(id,user_id,balance,gift_balance,currency,status,limit_mode,created_at,updated_at) VALUES($1,$1,$2,$3,'USD','active','finite',$4,$4)") + .bind(id).bind(balance).bind(gift).bind(at).execute(&pool).await.unwrap(); + } + for (id, user, cost) in [("a1", "alice", 1.0), ("a2", "alice", 2.0), ("b", "bob", 2.0), ("d", "deleted", 999.0)] { + sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,total_tokens,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES($1,$1,$2,'model','provider','completed','settled',100,$3,$3,$4,$5)") + .bind(id).bind(user).bind(cost).bind(at) + .bind(serde_json::json!({"analytics_attribution":{"is_standalone":false}})) + .execute(&pool).await.unwrap(); + } + // Old creation timestamps must not hide credits received in this period. + // Gift orders, plans, pending orders and boundary credits stay distinct. + for (id, user, kind, method, status, credit_minutes, amount) in [ + ("credit-start", "alice", "wallet_recharge", "stripe", "credited", Some(0), 100.0), + ("credit-later", "alice", "wallet_recharge", "admin_manual", "credited", Some(2), 11.0), + ("plan", "alice", "plan_purchase", "stripe", "credited", Some(3), 25.0), + ("gift", "alice", "wallet_recharge", "gift_code", "credited", Some(4), 7.0), + ("grant", "alice", "plan_purchase", "admin_grant", "credited", Some(5), 4.0), + ("pending", "alice", "wallet_recharge", "stripe", "pending", None, 999.0), + ("old", "alice", "wallet_recharge", "stripe", "credited", Some(-1), 999.0), + ("end", "alice", "wallet_recharge", "stripe", "credited", Some(60), 999.0), + ("bob-credit", "bob", "wallet_recharge", "stripe", "credited", Some(1), 20.0), + ("deleted-credit", "deleted", "wallet_recharge", "stripe", "credited", Some(1), 999.0), + ] { + sqlx::query("INSERT INTO payment_orders(id,order_no,wallet_id,user_id,order_kind,payment_method,status,amount_usd,refunded_amount_usd,created_at,paid_at,credited_at) VALUES($1,$1,$2,$2,$3,$4,$5,$6,0,$7,$8,$8)") + .bind(id).bind(user).bind(kind).bind(method).bind(status).bind(amount) + .bind(at - chrono::Duration::days(1)) + .bind(credit_minutes.map(|minutes| at + chrono::Duration::minutes(minutes))) + .execute(&pool).await.unwrap(); + } + // A later cumulative refund must not rewrite the original gross credit. + sqlx::query("UPDATE payment_orders SET refunded_amount_usd=10 WHERE id='credit-start'") + .execute(&pool).await.unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let query = UsageAnalyticsQuery { + from_unix_ms: at.timestamp_millis() as u64, + to_unix_ms: (at + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), view: UsageAnalyticsView::Users, + descending: true, limit: 1, ..Default::default() + }; + let first = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(first.users.len(), 1); + assert_eq!(first.users[0].user_id, "alice"); + assert_eq!(first.user_summary.as_ref().unwrap().user_count, 2); + assert_eq!(first.user_summary.as_ref().unwrap().active_user_count, 2); + assert_eq!(first.summary.enabled_users, 1); + assert_eq!(first.summary.request_count, 3); + assert_eq!(first.summary.billable_amount.as_deref(), Some("5.00000000")); + let finance = first.user_finance_summary.as_ref().unwrap(); + assert_eq!(finance.wallet_balance.as_deref(), Some("21.00000000")); + assert_eq!(finance.recharge_amount.as_deref(), Some("131.00000000")); + assert_eq!(finance.recharge_count, 3); + assert_eq!(finance.plan_purchase_amount.as_deref(), Some("25.00000000")); + assert_eq!(finance.plan_purchase_count, 1); + assert_eq!(finance.gift_credit_amount.as_deref(), Some("11.00000000")); + assert_eq!(finance.gift_credit_count, 2); + assert_eq!(first.users[0].finance.as_ref().unwrap().recharge_amount.as_deref(), Some("111.00000000")); + assert!(first.user_payments.is_none()); + let next = repo.query_usage_analytics(&UsageAnalyticsQuery { offset: 1, ..query.clone() }).await.unwrap(); + assert_eq!(next.users[0].user_id, "bob"); + assert_eq!(next.user_summary, first.user_summary); + assert_eq!(next.user_finance_summary, first.user_finance_summary); + let search = repo.query_usage_analytics(&UsageAnalyticsQuery { search: Some("bob".into()), ..query.clone() }).await.unwrap(); + assert_eq!(search.total, 1); + assert_eq!(search.summary.request_count, 1); + assert_eq!(search.user_finance_summary.unwrap().recharge_amount.as_deref(), Some("20.00000000")); + let detail = repo.query_usage_analytics(&UsageAnalyticsQuery { + credential_owner_id: Some("alice".into()), payment_limit: Some(2), payment_offset: Some(1), ..query.clone() + }).await.unwrap(); + let payments = detail.user_payments.unwrap(); + assert_eq!(payments.total, 5); + assert_eq!(payments.limit, 2); + assert_eq!(payments.offset, 1); + assert_eq!(payments.items[0].id, "gift"); + assert_eq!(payments.items[0].kind, "gift_credit"); + assert_eq!(payments.items[1].id, "plan"); + let empty = repo.query_usage_analytics(&UsageAnalyticsQuery { search: Some("missing".into()), ..query.clone() }).await.unwrap(); + assert_eq!(empty.total, 0); + assert_eq!(empty.summary.request_count, 0); + assert_eq!(empty.user_finance_summary.unwrap().wallet_balance.as_deref(), Some("0.00000000")); + sqlx::query("UPDATE wallets SET currency='EUR' WHERE user_id='alice'").execute(&pool).await.unwrap(); + let mixed = repo.query_usage_analytics(&query).await.unwrap(); + assert!(mixed.user_finance_summary.unwrap().wallet_balance.is_none()); + assert!(mixed.users[0].finance.as_ref().unwrap().wallet_balance.is_none()); + }).catch_unwind().await; + pool.close().await; + sqlx::query(&format!("DROP DATABASE {database} WITH (FORCE)")) + .execute(&admin) + .await + .unwrap(); + admin.close().await; + if let Err(error) = outcome { + std::panic::resume_unwind(error); + } +} + +#[tokio::test] +#[ignore = "requires local AETHER_TEST_DATABASE_URL with temporary database creation"] +async fn live_overview_dashboard_all_history_and_charts_share_canonical_metrics() { + use futures_util::FutureExt; + use std::panic::AssertUnwindSafe; + let connection = std::env::var("AETHER_TEST_DATABASE_URL") + .unwrap() + .parse::() + .unwrap(); + let admin = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(connection.clone()) + .await + .unwrap(); + let database = format!("overview_dashboard_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(&format!("CREATE DATABASE {database}")) + .execute(&admin) + .await + .unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect_with(connection.database(&database)) + .await + .unwrap(); + let outcome=AssertUnwindSafe(async { + crate::POSTGRES_MIGRATOR.run(&pool).await.unwrap(); + let repo=SqlxUsageReadRepository::new(pool.clone()); + let query=UsageDashboardAnalyticsQuery{timezone:"Asia/Shanghai".into()}; + let empty=repo.query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(empty.total.summary.request_count,0); assert_eq!(empty.total_from,None); + assert_eq!(empty.today.read_revision,empty.total.read_revision); + assert_eq!(empty.total.coverage.missing_bucket_count,0); + assert_eq!(empty.total.summary.billable_amount.as_deref(),Some("0.00000000")); + let coverage_start=Utc.with_ymd_and_hms(2020,1,1,0,0,0).unwrap(); + for index in 0..4 { + sqlx::query("INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start,source_revision,built_revision,coverage_status) VALUES('overview-v2','hour',$1,1,$2,'complete')") + .bind(coverage_start+chrono::Duration::hours(index)).bind(if index==1 || index==3 {0_i64}else{1_i64}).execute(&pool).await.unwrap(); + } + let coverage_query=UsageAnalyticsQuery{from_unix_ms:coverage_start.timestamp_millis()as u64,to_unix_ms:(coverage_start+chrono::Duration::hours(5)).timestamp_millis()as u64,timezone:"Asia/Kathmandu".into(),limit:1,..Default::default()}; + let mut coverage_tx=pool.begin().await.unwrap(); + let coverage=super::projection_reader::read_projection_coverage(&mut coverage_tx,&coverage_query,true).await.unwrap(); + assert_eq!(coverage.projection_through,Some((coverage_start+chrono::Duration::hours(1)).to_rfc3339())); + assert_eq!(coverage.dirty_bucket_count,2); assert_eq!(coverage.missing_bucket_count,1); + let partial_query=UsageAnalyticsQuery{from_unix_ms:coverage_query.from_unix_ms+30*60*1000,to_unix_ms:coverage_query.from_unix_ms+3*60*60*1000+30*60*1000,..coverage_query.clone()}; + let partial=super::projection_reader::read_projection_coverage(&mut coverage_tx,&partial_query,true).await.unwrap(); + assert_eq!(partial.projection_from,Some((coverage_start+chrono::Duration::hours(1)).to_rfc3339())); + assert_eq!(partial.projection_from,partial.projection_through); assert_eq!(partial.missing_bucket_count,0); assert_eq!(partial.dirty_bucket_count,1); + coverage_tx.rollback().await.unwrap(); + sqlx::query("DELETE FROM stats_bucket_state").execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start,source_revision,built_revision,coverage_status) VALUES('overview-v1','hour',$1,1,1,'complete')") + .bind(coverage_start).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO stats_overview_hourly(projection_version,bucket_start,dimensions,metrics) VALUES('overview-v1',$1,'{}',$2)") + .bind(coverage_start) + .bind(serde_json::json!({"request_count":999,"trusted_attribution_count":999,"billable_amount":"999.00000000"})) + .execute(&pool).await.unwrap(); + let legacy_query = UsageAnalyticsQuery { + from_unix_ms: coverage_start.timestamp_millis() as u64, + to_unix_ms: (coverage_start + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), + limit: 1, + ..Default::default() + }; + let legacy = repo.query_usage_analytics(&legacy_query).await.unwrap(); + assert_eq!(legacy.summary.request_count, 0); + assert_eq!(legacy.summary.trusted_attribution_count, 0); + assert_eq!(legacy.coverage.missing_bucket_count, 1); + assert_eq!(legacy.coverage.projection_through, Some(coverage_start.to_rfc3339())); + let legacy_dashboard = repo.query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(legacy_dashboard.total.summary.request_count, 0); + assert_eq!(legacy_dashboard.total.summary.billable_amount.as_deref(), Some("0.00000000")); + sqlx::query("UPDATE stats_bucket_state SET coverage_status='unrecoverable' WHERE projection_version='overview-v1'") + .execute(&pool).await.unwrap(); + assert_eq!(repo.query_usage_analytics(&legacy_query).await.unwrap().unrecoverable_bucket_count, 1); + let legacy_lost = repo.query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(legacy_lost.history_complete, Some(false)); + assert_eq!(legacy_lost.total.unrecoverable_bucket_count, 1); + assert_eq!(legacy_lost.total.summary.billable_amount, None); + assert_eq!(legacy_lost.total_from, Some(coverage_start.to_rfc3339())); + sqlx::query("DELETE FROM stats_overview_hourly WHERE projection_version='overview-v1'").execute(&pool).await.unwrap(); + sqlx::query("DELETE FROM stats_bucket_state WHERE projection_version='overview-v1'").execute(&pool).await.unwrap(); + let now=Utc::now(); + let day=query.today_start(now).unwrap(); + let old=(day-chrono::Duration::days(800)).with_timezone(&Utc); + sqlx::query("INSERT INTO users(id,username,email_verified,is_active) VALUES('user','user',false,true)").execute(&pool).await.unwrap(); + for (id,at,model,provider,status,money) in [ + ("old",old,"old-model","old-provider","completed",Some(2.0)), + ("before-today",day-chrono::Duration::milliseconds(1),"current-model","new-provider","completed",Some(0.2)), + ("today",day,"current-model","new-provider","completed",Some(0.5)), + ("pending",day,"current-model","new-provider","pending",None), + ("parent",day,"current-model","new-provider","completed",Some(99.0)), + ("future",now+chrono::Duration::days(1),"future-model","future-provider","completed",Some(999.0)), + ] { + let metadata=serde_json::json!({"analytics_attribution":{"is_standalone":false,"record_kind":if id=="parent" {"session"}else{"request"}},"usage_pricing_available":money.is_some(),"analytics_measurement":{"source":"reported"}}); + sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_id,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,total_cost_usd,created_at,request_metadata) VALUES($1,$1,'user',$2,$3,$3,$4,$5,100,20,120,$6,$6,$7,$8)") + .bind(id).bind(model).bind(provider).bind(status).bind(if money.is_some(){"settled"}else{"pending"}).bind(money.unwrap_or(0.0)).bind(at).bind(metadata).execute(&pool).await.unwrap(); + } + let snapshot=repo.query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(snapshot.today.summary.request_count,2); + assert_eq!(snapshot.today.summary.successful_request_count,1); + assert_eq!(snapshot.today.summary.in_flight_request_count,1); + assert_eq!(snapshot.today.summary.billable_amount.as_deref(),Some("0.50000000")); + assert_eq!(snapshot.today.summary.pricing_available_count,1); + assert_eq!(snapshot.total.summary.request_count,4); + assert_eq!(snapshot.total.summary.total_tokens,480); + assert_eq!(snapshot.total.summary.billable_amount.as_deref(),Some("2.70000000")); + assert_eq!(snapshot.total.summary.enabled_users,1); + assert_eq!(snapshot.total_from,Some(old.to_rfc3339())); + assert_eq!(snapshot.history_complete,None); + assert_eq!(snapshot.today.read_revision,snapshot.total.read_revision); + assert_eq!(snapshot.today.generated_at,snapshot.total.generated_at); + let old_hour=chrono::DateTime::from_timestamp(old.timestamp()/3600*3600,0).unwrap(); + assert!(repo.rebuild_overview_bucket("hour",old_hour).await.unwrap()); + let projected=repo.query_dashboard_analytics(&query).await.unwrap(); + let raw=SqlxUsageReadRepository::new(pool.clone()).with_overview_projection_reads(false).query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(projected.total.summary,raw.total.summary); + assert_eq!(projected.total.coverage.projection_through,Some((old_hour+chrono::Duration::hours(1)).to_rfc3339())); + let chart_query=UsageAnalyticsQuery{from_unix_ms:(day-chrono::Duration::days(1)).timestamp_millis()as u64,to_unix_ms:now.timestamp_millis()as u64,timezone:query.timezone.clone(),view:UsageAnalyticsView::DashboardCharts,limit:1,..Default::default()}; + let charts=repo.query_usage_analytics(&chart_query).await.unwrap(); + assert_eq!(charts.summary.request_count,3); assert_eq!(charts.rows.len(),2); + assert_eq!(charts.model_rows.len(),2); assert_eq!(charts.provider_rows.len(),1); + assert_eq!(charts.provider_rows[0].metrics.billable_amount.as_deref(),Some("0.70000000")); + assert_eq!(charts.model_rows.iter().map(|row|row.metrics.request_count).sum::(),3); + // Performance model rows span the whole selected period, independently of + // page limits, and retain the same metrics with projection reads enabled. + let performance_query = UsageAnalyticsQuery { + view: UsageAnalyticsView::Performance, + offset: 1, + ..chart_query.clone() + }; + let raw_performance = SqlxUsageReadRepository::new(pool.clone()) + .with_overview_projection_reads(false) + .query_usage_analytics(&performance_query).await.unwrap(); + assert!(repo.rebuild_overview_bucket("hour", day).await.unwrap()); + let projected_performance = repo.query_usage_analytics(&performance_query).await.unwrap(); + assert_eq!(projected_performance.model_rows, raw_performance.model_rows); + assert_eq!(projected_performance.model_rows.len(), 1); + let model = &projected_performance.model_rows[0]; + assert_eq!(model.id.as_deref(), Some("current-model")); + assert_eq!(model.bucket_start, None); + assert_eq!(model.metrics.request_count, 3); + assert_eq!(model.metrics.successful_request_count, 2); + assert_eq!(model.metrics.in_flight_request_count, 1); + assert_eq!(model.metrics.billable_amount.as_deref(), Some("0.70000000")); + sqlx::query("DELETE FROM usage WHERE request_id='old'").execute(&pool).await.unwrap(); + let deleted=repo.query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(deleted.total.summary.request_count,3); + assert_eq!(deleted.history_complete,Some(false)); + assert_eq!(deleted.total.unrecoverable_bucket_count,1); + sqlx::query("ALTER TABLE stats_daily ALTER COLUMN date TYPE bigint USING EXTRACT(EPOCH FROM date)::bigint").execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO stats_daily(id,date,total_requests) VALUES('legacy-epoch',$1,1)").bind((old-chrono::Duration::days(1)).timestamp()).execute(&pool).await.unwrap(); + assert_eq!(repo.query_dashboard_analytics(&query).await.unwrap().history_complete,Some(false)); + for at in ["2026-09-05T12:00:00Z","2026-09-06T12:00:00Z","2026-09-07T12:00:00Z"] { + sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at) VALUES($1,$1,'santiago-model','santiago','completed','settled',0.1,0.1,$2)").bind(at).bind(chrono::DateTime::parse_from_rfc3339(at).unwrap()).execute(&pool).await.unwrap(); + } + let santiago=repo.query_usage_analytics(&UsageAnalyticsQuery{from_unix_ms:chrono::DateTime::parse_from_rfc3339("2026-09-05T04:00:00Z").unwrap().timestamp_millis()as u64,to_unix_ms:chrono::DateTime::parse_from_rfc3339("2026-09-08T03:00:00Z").unwrap().timestamp_millis()as u64,timezone:"America/Santiago".into(),view:UsageAnalyticsView::DashboardCharts,model:Some("santiago-model".into()),limit:1,..Default::default()}).await.unwrap(); + assert_eq!(santiago.summary.request_count,3); assert_eq!(santiago.rows.len(),3); assert_eq!(santiago.model_rows.len(),3); + assert_eq!(santiago.summary.billable_amount.as_deref(),Some("0.30000000")); + for (series,model) in santiago.rows.iter().zip(&santiago.model_rows) { + assert_eq!(series.metrics.request_count,1); assert_eq!(series.bucket_start,model.bucket_start); assert_eq!(series.metrics.billable_amount,model.metrics.billable_amount); + } + sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,created_at) SELECT 'limit-'||n,'limit-'||n,'model-'||n,'provider','completed',$1 FROM generate_series(1,10001)n").bind(day).execute(&pool).await.unwrap(); + assert!(repo.query_usage_analytics(&chart_query).await.unwrap_err().to_string().contains("10000 groups")); + sqlx::query("DELETE FROM usage").execute(&pool).await.unwrap(); + let lost=repo.query_dashboard_analytics(&query).await.unwrap(); + assert_eq!(lost.today.summary.request_count,0); + assert_eq!(lost.today.summary.billable_amount,None); + assert_eq!(lost.total.summary.billable_amount,None); + assert_eq!(lost.history_complete,Some(false)); + }).catch_unwind().await; + pool.close().await; + sqlx::query(&format!("DROP DATABASE {database}")) + .execute(&admin) + .await + .unwrap(); + if let Err(panic) = outcome { + std::panic::resume_unwind(panic); + } +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_canonical_queries_and_dirty_rebuild() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let user = uuid::Uuid::new_v4().to_string(); + let zero_user = uuid::Uuid::new_v4().to_string(); + let model = format!("overview-test-{}", uuid::Uuid::new_v4().simple()); + for (id, suffix) in [(&user, "used"), (&zero_user, "zero")] { + sqlx::query("INSERT INTO users(id,username,email,email_verified,password_hash,role,is_active,is_deleted) VALUES($1,$2,$3,false,'test','user',true,false)") + .bind(id).bind(format!("{model}-{suffix}")).bind(format!("{id}@test.invalid")).execute(&pool).await.unwrap(); + } + let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() + + chrono::Duration::hours((uuid::Uuid::new_v4().as_u128() % 8760) as i64); + let request_ids = (0..5) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>(); + for (index, status) in ["completed", "failed", "failed", "cancelled", "pending"] + .iter() + .enumerate() + { + let origin = if index == 1 { "upstream" } else { "client" }; + let metadata = serde_json::json!({"analytics_attribution":{"is_standalone":false},"analytics_failure":{"origin":origin,"stage":"authentication","reason":"invalid_credentials"}}); + sqlx::query("INSERT INTO usage(id,request_id,user_id,provider_name,model,api_format,status,billing_status,input_tokens,output_tokens,response_time_ms,first_byte_time_ms,is_stream,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES($1,$1,$2,'Provider',$3,'openai:chat',$4,$5,100,20,1000,100,true,1.25000001,0.75000001,$6,$7)") + .bind(&request_ids[index]).bind(&user).bind(&model).bind(status).bind(if index == 0 {"settled"} else {"pending"}).bind(start + chrono::Duration::minutes(index as i64)).bind(metadata).execute(&pool).await.unwrap(); + } + let mut query = UsageAnalyticsQuery { + from_unix_ms: start.timestamp_millis() as u64, + to_unix_ms: (start + chrono::Duration::hours(2)).timestamp_millis() as u64, + timezone: "UTC".into(), + model: Some(model.clone()), + limit: 25, + descending: true, + ..Default::default() + }; + let summary = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(summary.summary.request_count, 5); + assert_eq!(summary.summary.successful_request_count, 1); + assert_eq!(summary.summary.failed_request_count, 2); + assert_eq!(summary.summary.cancelled_request_count, 1); + assert_eq!(summary.summary.in_flight_request_count, 1); + assert_eq!( + summary.summary.billable_amount.as_deref(), + Some("0.75000001") + ); + assert_eq!(summary.summary.trusted_attribution_count, 5); + assert_eq!(summary.summary.allocation_available_count, 0); + query.view = UsageAnalyticsView::Users; + query.search = Some(model.clone()); + query.limit = 1; + let users = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(users.total, 2); + assert_eq!(users.users[0].user_id, user); + query.offset = 1; + let users = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(users.users[0].user_id, zero_user); + assert_eq!(users.users[0].metrics.request_count, 0); + query.view = UsageAnalyticsView::Performance; + query.granularity = UsageAnalyticsGranularity::Hour; + let performance = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(performance.rows.len(), 2); + assert_eq!(performance.rows[1].metrics.request_count, 0); + assert_eq!(performance.provider_rows.len(), 1); + assert_eq!(performance.provider_timeline_rows.len(), 1); + assert_eq!(performance.summary.first_byte_sample_count, 5); + assert_eq!(performance.summary.output_tps_sample_count, 5); + let health = repo + .summarize_health_observations(&HealthObservationQuery { + from_unix_ms: query.from_unix_ms, + to_unix_ms: query.to_unix_ms, + object_kind: HealthObservationObjectKind::Model, + object_values: Some(vec![model.clone()]), + segments: 4, + }) + .await + .unwrap(); + assert_eq!(health.overall.service_succeeded_count, 1); + assert_eq!(health.overall.service_failed_count, 1); + assert_eq!(health.overall.excluded_count, 2); + assert_eq!(health.overall.request_count, 5); + let empty = repo + .summarize_health_observations(&HealthObservationQuery { + from_unix_ms: query.from_unix_ms, + to_unix_ms: query.to_unix_ms, + object_kind: HealthObservationObjectKind::Model, + object_values: Some(vec![]), + segments: 4, + }) + .await + .unwrap(); + assert_eq!(empty.overall.request_count, 0); + + assert!(repo.rebuild_overview_bucket("hour", start).await.unwrap()); + let projected = repo + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(projected.summary, summary.summary); + assert_eq!( + projected.coverage.projection_through, + Some((start + chrono::Duration::hours(1)).to_rfc3339()) + ); + let raw = SqlxUsageReadRepository::new(pool.clone()) + .with_overview_projection_reads(false) + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(raw.summary, projected.summary); + assert!(!raw.coverage.read_enabled); + // UTC bucket membership must not depend on the database session timezone. + // Partial edge hours still come entirely from raw facts, without duplication. + let mut timezone_tx = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL TIME ZONE 'Asia/Kathmandu'") + .execute(&mut *timezone_tx) + .await + .unwrap(); + for (from_offset_ms, to_offset_ms, expected_count) in + [(0, 7_200_000, 5), (30_000, 7_200_000, 4), (0, 120_000, 2)] + { + let range = UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + from_unix_ms: query.from_unix_ms + from_offset_ms, + to_unix_ms: query.from_unix_ms + to_offset_ms, + ..query.clone() + }; + let projected = super::analytics::read_analytics_metrics(&mut timezone_tx, &range, true) + .await + .unwrap(); + let raw = super::analytics::read_analytics_metrics(&mut timezone_tx, &range, false) + .await + .unwrap(); + assert_eq!(projected.request_count, expected_count); + assert_eq!(projected, raw); + let projected_total = + super::dashboard::read_dashboard_total_metrics(&mut timezone_tx, &range, true) + .await + .unwrap(); + let raw_total = + super::dashboard::read_dashboard_total_metrics(&mut timezone_tx, &range, false) + .await + .unwrap(); + assert_eq!(projected_total, raw_total); + assert_dashboard_total_matches_canonical(&raw_total, &raw); + } + timezone_tx.rollback().await.unwrap(); + let partial = repo + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + from_unix_ms: query.from_unix_ms + 30_000, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(partial.summary.request_count, 4); + let state = sqlx::query("SELECT source_revision,built_revision FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1").bind(start).fetch_one(&pool).await.unwrap(); + assert_eq!( + state.get::("source_revision"), + state.get::("built_revision") + ); + sqlx::query("UPDATE usage SET response_time_ms=2000 WHERE request_id=$1") + .bind(&request_ids[0]) + .execute(&pool) + .await + .unwrap(); + let state = sqlx::query("SELECT source_revision,built_revision FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1").bind(start).fetch_one(&pool).await.unwrap(); + // Bucket state stays unchanged on the foreground write; pending events + // invalidate the projection before the background merger consumes them. + assert_eq!(state.get::("source_revision"), state.get::("built_revision")); + let pending: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM stats_overview_dirty_events WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1)") + .bind(start).fetch_one(&pool).await.unwrap(); + assert!(pending); + let dirty = repo + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(dirty.summary.request_count, 5); + assert_eq!(dirty.summary.latency_sum_ms, 6000.0); + assert!(repo.rebuild_overview_bucket("hour", start).await.unwrap()); + let rebuilt = repo + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(rebuilt.summary, dirty.summary); + sqlx::query("UPDATE users SET is_deleted=true WHERE id=$1") + .bind(&user) + .execute(&pool) + .await + .unwrap(); + let actor: Option = sqlx::query_scalar( + "SELECT credential_owner_id FROM usage_analytics_facts_v1 WHERE request_id=$1", + ) + .bind(&request_ids[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(actor, None); + sqlx::query("DELETE FROM usage WHERE request_id=ANY($1)") + .bind(&request_ids) + .execute(&pool) + .await + .unwrap(); + let remaining: i64 = sqlx::query_scalar( + "SELECT count(*) FROM usage_attribution_snapshots WHERE request_id=ANY($1)", + ) + .bind(&request_ids) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 0); + assert!(!repo.rebuild_overview_bucket("hour", start).await.unwrap()); + let deleted = repo + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Summary, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(deleted.unrecoverable_bucket_count, 1); + sqlx::query("DELETE FROM users WHERE id=ANY($1)") + .bind(vec![user, zero_user]) + .execute(&pool) + .await + .unwrap(); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_concurrent_revision_aborts_publication() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let bucket = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap() + + chrono::Duration::hours((uuid::Uuid::new_v4().as_u128() % 8760) as i64); + sqlx::query("INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start,source_revision,built_revision) VALUES('overview-v2','hour',$1,1,0) ON CONFLICT DO NOTHING").bind(bucket).execute(&pool).await.unwrap(); + let mut builder = pool.begin().await.unwrap(); + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .execute(&mut *builder) + .await + .unwrap(); + let revision:i64=sqlx::query_scalar("SELECT source_revision FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1").bind(bucket).fetch_one(&mut *builder).await.unwrap(); + sqlx::query("INSERT INTO stats_overview_hourly(projection_version,bucket_start,dimensions,metrics) VALUES('overview-v2',$1,'{}','{}')").bind(bucket).execute(&mut *builder).await.unwrap(); + sqlx::query("UPDATE stats_bucket_state SET source_revision=source_revision+1 WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1").bind(bucket).execute(&pool).await.unwrap(); + let publish=sqlx::query("UPDATE stats_bucket_state SET built_revision=$2 WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1 AND source_revision=$2").bind(bucket).bind(revision).execute(&mut *builder).await; + assert!(publish.is_err() || publish.unwrap().rows_affected() == 0); + builder.rollback().await.unwrap(); + let rows:i64=sqlx::query_scalar("SELECT count(*) FROM stats_overview_hourly WHERE projection_version='overview-v2' AND bucket_start=$1").bind(bucket).fetch_one(&pool).await.unwrap(); + assert_eq!(rows, 0); + sqlx::query("DELETE FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1").bind(bucket).execute(&pool).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_dirty_events_merge_without_shared_writer_bucket_lock() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let bucket = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap() + + chrono::Duration::hours((uuid::Uuid::new_v4().as_u128() % 8760) as i64); + sqlx::query("DELETE FROM stats_overview_dirty_events WHERE bucket_start=$1") + .bind(bucket) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1") + .bind(bucket) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO stats_overview_dirty_events(transaction_id,projection_version,granularity,bucket_start,unrecoverable) VALUES($1,'overview-v2','hour',$2,false),($3,'overview-v2','hour',$2,true)") + .bind((uuid::Uuid::new_v4().as_u128() % 9_000_000_000_000_000_000u128) as i64) + .bind(bucket) + .bind((uuid::Uuid::new_v4().as_u128() % 9_000_000_000_000_000_000u128) as i64) + .execute(&pool) + .await + .unwrap(); + assert_eq!(repo.merge_overview_dirty_events().await.unwrap(), 2); + let row: (i64, String, Option) = sqlx::query_as("SELECT source_revision,coverage_status,last_error FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1") + .bind(bucket) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row, (2, "unrecoverable".into(), Some("retained usage facts were deleted".into()))); + let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM stats_overview_dirty_events WHERE bucket_start=$1") + .bind(bucket) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(remaining, 0); + sqlx::query("DELETE FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1") + .bind(bucket) + .execute(&pool) + .await + .unwrap(); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_settlement_allocations_preserve_unlimited_and_finite_wallets() { + use aether_data_contracts::repository::settlement::{ + SettlementWriteRepository, UsageSettlementInput, + }; + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let repository = crate::SqlxSettlementRepository::new(pool.clone()); + for mode in [ + "unlimited", + "finite", + "mixed_quota", + "quota_only", + "minimum", + ] { + let user = uuid::Uuid::new_v4().to_string(); + let wallet = uuid::Uuid::new_v4().to_string(); + let request = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users(id,username,email_verified) VALUES($1,$1,false)") + .bind(&user) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO wallets(id,user_id,balance,gift_balance,limit_mode,created_at,updated_at) VALUES($1,$2,1,2,$3,NOW(),NOW())").bind(&wallet).bind(&user).bind(if mode=="unlimited" {"unlimited"}else{"finite"}).execute(&pool).await.unwrap(); + let quota: f64 = match mode { + "mixed_quota" => 2.0, + "quota_only" => 7.0, + _ => 0.0, + }; + if quota > 0.0 { + let grant = serde_json::json!([{"type":"daily_quota","daily_quota_usd":quota,"reset_timezone":"UTC","allow_wallet_overage":true}]); + sqlx::query("INSERT INTO billing_plans(id,title,price_amount,duration_unit,duration_value,entitlements_json,created_at,updated_at) VALUES($1,'test',1,'month',1,$2,NOW(),NOW())").bind(&user).bind(&grant).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO payment_orders(id,order_no,wallet_id,user_id,amount_usd,payment_method,created_at) VALUES($1,$1,$2,$1,1,'test',NOW())").bind(&user).bind(&wallet).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO user_plan_entitlements(id,user_id,plan_id,payment_order_id,starts_at,expires_at,entitlements_snapshot,created_at,updated_at) VALUES($1,$1,$1,$1,NOW()-INTERVAL '1 hour',NOW()+INTERVAL '1 day',$2,NOW(),NOW())").bind(&user).bind(&grant).execute(&pool).await.unwrap(); + } + sqlx::query("INSERT INTO usage(id,request_id,user_id,provider_name,model,status,billing_status) VALUES($1,$1,$2,'test','test','completed','pending')").bind(&request).bind(&user).execute(&pool).await.unwrap(); + let cost = if mode == "minimum" { 0.00000001 } else { 5.0 }; + let input = UsageSettlementInput { + request_id: request.clone(), + user_id: Some(user.clone()), + api_key_id: None, + api_key_is_standalone: false, + provider_id: None, + status: "completed".into(), + billing_status: "pending".into(), + total_cost_usd: cost, + actual_total_cost_usd: cost, + finalized_at_unix_secs: None, + }; + assert_eq!( + repository + .settle_usage(input.clone()) + .await + .unwrap() + .unwrap() + .billing_status, + "settled" + ); + let snapshot=sqlx::query("SELECT quota_covered_amount_usd::text AS quota,wallet_consumed_amount_usd::text AS consumed,wallet_debit_amount_usd::text AS debit,wallet_recharge_debit_usd::text AS recharge,wallet_gift_debit_usd::text AS gift,wallet_overdraft_usd::text AS overdraft,allocation_status FROM usage_settlement_snapshots WHERE request_id=$1").bind(&request).fetch_one(&pool).await.unwrap(); + let quota_covered = quota.min(cost); + let consumed = cost - quota_covered; + assert_eq!( + snapshot.get::("quota"), + format!("{quota_covered:.8}") + ); + assert_eq!( + snapshot.get::("consumed"), + format!("{consumed:.8}") + ); + assert_eq!(snapshot.get::("allocation_status"), "complete"); + if mode == "unlimited" { + for field in ["debit", "recharge", "gift", "overdraft"] { + assert_eq!(snapshot.get::(field), "0.00000000"); + } + } else { + assert_eq!(snapshot.get::("debit"), format!("{consumed:.8}")); + assert_eq!( + snapshot.get::("recharge"), + format!("{:.8}", consumed.min(1.0)) + ); + assert_eq!( + snapshot.get::("gift"), + format!("{:.8}", (consumed - 1.0).clamp(0.0, 2.0)) + ); + assert_eq!( + snapshot.get::("overdraft"), + if mode == "finite" { + "2.00000000" + } else { + "0.00000000" + } + ); + } + repository.settle_usage(input).await.unwrap(); + let wallet_consumed: String = + sqlx::query_scalar("SELECT total_consumed::text FROM wallets WHERE id=$1") + .bind(&wallet) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(wallet_consumed, format!("{consumed:.8}")); + sqlx::query("DELETE FROM usage_settlement_snapshots WHERE request_id=$1") + .bind(&request) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM usage WHERE request_id=$1") + .bind(&request) + .execute(&pool) + .await + .unwrap(); + if quota > 0.0 { + sqlx::query("DELETE FROM user_plan_entitlements WHERE id=$1") + .bind(&user) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM billing_plans WHERE id=$1") + .bind(&user) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM payment_orders WHERE id=$1") + .bind(&user) + .execute(&pool) + .await + .unwrap(); + } + sqlx::query("DELETE FROM wallets WHERE id=$1") + .bind(&wallet) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id=$1") + .bind(&user) + .execute(&pool) + .await + .unwrap(); + } +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_worker_skips_unrecoverable_and_backoff_buckets() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let start = Utc.with_ymd_and_hms(1880, 1, 1, 0, 0, 0).unwrap() + + chrono::Duration::hours((uuid::Uuid::new_v4().as_u128() % 10000) as i64); + for index in 0..50 { + sqlx::query("INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start,source_revision,coverage_status,last_failed_at) VALUES('overview-v2','hour',$1,1,$2,$3)") + .bind(start+chrono::Duration::hours(index)).bind(if index%2==0 {"unrecoverable"}else{"unbuilt"}) + .bind(if index%2==0 {None}else{Some(Utc::now())}).execute(&pool).await.unwrap(); + } + let available = start + chrono::Duration::hours(50); + sqlx::query("INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start,source_revision) VALUES('overview-v2','hour',$1,1)").bind(available).execute(&pool).await.unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + assert!( + repo.rebuild_overview_buckets(available + chrono::Duration::hours(1), 1) + .await + .unwrap() + > 0 + ); + let clean:bool=sqlx::query_scalar("SELECT source_revision=built_revision FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start=$1").bind(available).fetch_one(&pool).await.unwrap(); + assert!(clean); + assert_eq!( + repo.rebuild_overview_buckets(available + chrono::Duration::hours(1), 1) + .await + .unwrap(), + 0 + ); + sqlx::query("DELETE FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND bucket_start BETWEEN $1 AND $2").bind(start).bind(available).execute(&pool).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_account_attribution_corrections_and_late_events() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let owner = uuid::Uuid::new_v4().to_string(); + let unrelated_user = uuid::Uuid::new_v4().to_string(); + let request = uuid::Uuid::new_v4().to_string(); + for user in [&owner, &unrelated_user] { + sqlx::query("INSERT INTO users(id,username,email_verified) VALUES($1,$1,false)") + .bind(user) + .execute(&pool) + .await + .unwrap(); + } + let metadata = serde_json::json!({"analytics_attribution":{"is_standalone":false,"actor_user_id":unrelated_user},"analytics_failure":{"origin":"upstream","reason":"provider_error"}}); + sqlx::query("INSERT INTO usage(id,request_id,user_id,provider_name,model,status,request_metadata) VALUES($1,$1,$2,'test',$1,'failed',$3)").bind(&request).bind(&owner).bind(metadata.clone()).execute(&pool).await.unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let query = UsageAnalyticsQuery { + from_unix_ms: (Utc::now() - chrono::Duration::hours(1)).timestamp_millis() as u64, + to_unix_ms: (Utc::now() + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), + view: UsageAnalyticsView::Users, + model: Some(request.clone()), + attribution_kind: Some("employee".into()), + has_usage: Some(true), + limit: 100, + ..Default::default() + }; + let result = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(result.total, 1); + assert_eq!(result.users[0].user_id, owner); + let identity: (String, String, String) = sqlx::query_as( + "SELECT actor_user_id,credential_owner_id,attribution_source FROM usage_analytics_facts_v1 WHERE request_id=$1", + ) + .bind(&request) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + identity, + (owner.clone(), owner.clone(), "user_account".into()) + ); + sqlx::query("UPDATE usage SET request_metadata='{}',status='completed' WHERE request_id=$1") + .bind(&request) + .execute(&pool) + .await + .unwrap(); + let result = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(result.users[0].user_id, owner); + let failure: Option = + sqlx::query_scalar("SELECT failure_origin FROM usage WHERE request_id=$1") + .bind(&request) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(failure, None); + let correction = UsageAttributionSnapshot { + request_id: request.clone(), + actor_user_id: Some(owner.clone()), + credential_owner_id: Some(owner.clone()), + attribution_kind: "employee".into(), + attribution_source: "user_account".into(), + record_kind: "session".into(), + parent_request_id: None, + schema_version: 1, + attribution_revision: 3, + }; + assert!(repo + .correct_usage_attribution(&correction, 2) + .await + .unwrap()); + assert!(!repo + .correct_usage_attribution(&correction, 2) + .await + .unwrap()); + sqlx::query("UPDATE usage SET request_metadata=$2 WHERE request_id=$1") + .bind(&request) + .bind(metadata.clone()) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + repo.query_usage_analytics(&query) + .await + .unwrap() + .summary + .request_count, + 0 + ); + sqlx::query("UPDATE users SET is_deleted=true WHERE id=$1") + .bind(&owner) + .execute(&pool) + .await + .unwrap(); + let identities:(Option,Option)=sqlx::query_as("SELECT actor_user_id,credential_owner_id FROM usage_analytics_facts_v1 WHERE request_id=$1").bind(&request).fetch_one(&pool).await.unwrap(); + assert_eq!(identities, (None, None)); + sqlx::query("UPDATE users SET is_deleted=false WHERE id=$1") + .bind(&owner) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE usage SET request_metadata=$2 WHERE request_id=$1") + .bind(&request) + .bind(metadata) + .execute(&pool) + .await + .unwrap(); + let anonymized: (Option, Option, i64) = sqlx::query_as( + "SELECT actor_user_id,credential_owner_id,attribution_revision FROM usage_attribution_snapshots WHERE request_id=$1", + ) + .bind(&request) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(anonymized, (None, None, 13)); + let identities: (Option, Option) = sqlx::query_as( + "SELECT actor_user_id,credential_owner_id FROM usage_analytics_facts_v1 WHERE request_id=$1", + ) + .bind(&request) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(identities, (None, None)); + sqlx::query("DELETE FROM usage WHERE request_id=$1") + .bind(&request) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id=ANY($1)") + .bind(vec![owner, unrelated_user]) + .execute(&pool) + .await + .unwrap(); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_existing_key_types_classify_usage_without_snapshots() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let owner = uuid::Uuid::new_v4().to_string(); + let member_key = uuid::Uuid::new_v4().to_string(); + let standalone_key = uuid::Uuid::new_v4().to_string(); + let requests = [ + uuid::Uuid::new_v4().to_string(), + uuid::Uuid::new_v4().to_string(), + ]; + sqlx::query("INSERT INTO users(id,username,email_verified) VALUES($1,$1,false)") + .bind(&owner) + .execute(&pool) + .await + .unwrap(); + for (key, is_standalone) in [(&member_key, false), (&standalone_key, true)] { + sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,is_standalone) VALUES($1,$2,$1,$3)") + .bind(key) + .bind(&owner) + .bind(is_standalone) + .execute(&pool) + .await + .unwrap(); + } + for (request, key) in requests.iter().zip([&member_key, &standalone_key]) { + sqlx::query("INSERT INTO usage(id,request_id,user_id,api_key_id,provider_name,model,status) VALUES($1,$1,$2,$3,'test',$2,'completed')") + .bind(request).bind(&owner).bind(key).execute(&pool).await.unwrap(); + } + // Simulate retained usage written before attribution snapshots existed. + sqlx::query("DELETE FROM usage_attribution_snapshots WHERE request_id=ANY($1)") + .bind(requests.to_vec()) + .execute(&pool) + .await + .unwrap(); + for (request, actor, kind, source) in [ + ( + &requests[0], + Some(owner.clone()), + "employee", + "user_account", + ), + (&requests[1], None, "standalone", "standalone_key"), + ] { + let identity: (Option, Option, String, String) = sqlx::query_as( + "SELECT actor_user_id,credential_owner_id,attribution_kind,attribution_source FROM usage_analytics_facts_v1 WHERE request_id=$1", + ) + .bind(request) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + identity, + (actor, Some(owner.clone()), kind.into(), source.into()) + ); + } + let repo = SqlxUsageReadRepository::new(pool.clone()); + let query = UsageAnalyticsQuery { + from_unix_ms: (Utc::now() - chrono::Duration::hours(1)).timestamp_millis() as u64, + to_unix_ms: (Utc::now() + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), + model: Some(owner.clone()), + limit: 100, + ..Default::default() + }; + let summary = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(summary.summary.request_count, 2); + assert_eq!(summary.summary.trusted_attribution_count, 1); + let users = repo + .query_usage_analytics(&UsageAnalyticsQuery { + view: UsageAnalyticsView::Users, + attribution_kind: Some("employee".into()), + has_usage: Some(true), + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(users.total, 1); + assert_eq!(users.users[0].user_id, owner); + assert_eq!(users.users[0].metrics.request_count, 1); + let standalone = repo + .query_usage_analytics(&UsageAnalyticsQuery { + attribution_kind: Some("standalone".into()), + ..query + }) + .await + .unwrap(); + assert_eq!(standalone.summary.request_count, 1); + assert_eq!(standalone.summary.trusted_attribution_count, 0); + let snapshots: i64 = sqlx::query_scalar( + "SELECT count(*) FROM usage_attribution_snapshots WHERE request_id=ANY($1)", + ) + .bind(requests.to_vec()) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(snapshots, 0); + sqlx::query("DELETE FROM usage WHERE request_id=ANY($1)") + .bind(requests.to_vec()) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM api_keys WHERE id=ANY($1)") + .bind(vec![member_key, standalone_key]) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id=$1") + .bind(&owner) + .execute(&pool) + .await + .unwrap(); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_cache_pricing_and_record_drilldown() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let owner = uuid::Uuid::new_v4().to_string(); + let request = uuid::Uuid::new_v4().to_string(); + let other = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users(id,username,email_verified) VALUES($1,$1,false)") + .bind(&owner) + .execute(&pool) + .await + .unwrap(); + let metadata = serde_json::json!({"analytics_attribution":{"is_standalone":false},"analytics_measurement":{"source":"reported"}}); + for (id, latency) in [(&request, 6000), (&other, 100)] { + sqlx::query("INSERT INTO usage(id,request_id,user_id,api_key_id,provider_id,provider_name,model,api_format,endpoint_kind,request_type,is_stream,has_format_conversion,status,billing_status,response_time_ms,input_tokens,output_tokens,cache_read_input_tokens,request_metadata) VALUES($1,$1,$2,$2,$2,'test',$2,'openai:chat','chat','chat',true,true,'completed','settled',$3,1000,20,100,$4)") + .bind(id).bind(&owner).bind(latency).bind(metadata.clone()).execute(&pool).await.unwrap(); + } + sqlx::query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,input_price_per_1m,billing_cache_read_cost_usd,billing_cache_creation_cost_usd) VALUES($1,'settled',2,0.00005,0.00008)").bind(&request).execute(&pool).await.unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let query = UsageAnalyticsQuery { + from_unix_ms: (Utc::now() - chrono::Duration::hours(1)).timestamp_millis() as u64, + to_unix_ms: (Utc::now() + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), + model: Some(owner.clone()), + limit: 100, + ..Default::default() + }; + let result = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(result.summary.reported_usage_count, 2); + assert_eq!(result.summary.cache_pricing_available_count, 1); + assert_eq!( + result.summary.cache_estimated_full_cost_amount.as_deref(), + Some("0.00020000") + ); + assert_eq!( + result.summary.cache_read_cost_amount.as_deref(), + Some("0.00005000") + ); + assert_eq!( + result.summary.cache_creation_cost_amount.as_deref(), + Some("0.00008000") + ); + let list = UsageAuditListQuery { + provider_id: Some(owner.clone()), + api_key_id: Some(owner.clone()), + actor_user_id: Some(owner.clone()), + attribution_kind: Some("employee".into()), + slow_threshold_ms: Some(5000), + endpoint_kind: Some("chat".into()), + request_type: Some("chat".into()), + has_format_conversion: Some(true), + is_stream: Some(true), + ..Default::default() + }; + assert_eq!(repo.count_usage_audits(&list).await.unwrap(), 1); + assert_eq!( + repo.list_usage_audits(&list).await.unwrap()[0].request_id, + request + ); + let search = UsageAuditKeywordSearchQuery { + provider_id: list.provider_id.clone(), + api_key_id: list.api_key_id.clone(), + request_id: Some(request.clone()), + actor_user_id: list.actor_user_id.clone(), + attribution_kind: list.attribution_kind.clone(), + slow_threshold_ms: list.slow_threshold_ms, + endpoint_kind: list.endpoint_kind.clone(), + request_type: list.request_type.clone(), + has_format_conversion: list.has_format_conversion, + is_stream: list.is_stream, + keywords: vec![owner.clone()], + ..Default::default() + }; + assert_eq!( + repo.count_usage_audits_by_keyword_search(&search) + .await + .unwrap(), + 1 + ); + assert_eq!( + repo.list_usage_audits_by_keyword_search(&search) + .await + .unwrap()[0] + .request_id, + request + ); + assert_eq!( + repo.count_usage_audits(&UsageAuditListQuery { + provider_id: Some("no-such-provider".into()), + ..list.clone() + }) + .await + .unwrap(), + 0 + ); + assert_eq!( + repo.count_usage_audits_by_keyword_search(&UsageAuditKeywordSearchQuery { + request_id: Some(other.clone()), + ..search + }) + .await + .unwrap(), + 0 + ); + sqlx::query("UPDATE usage SET request_metadata=request_metadata::jsonb||'{\"usage_available\":false}'::jsonb WHERE request_id=$1").bind(&request).execute(&pool).await.unwrap(); + let unavailable = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(unavailable.summary.reported_usage_count, 1); + assert_eq!(unavailable.summary.unknown_usage_count, 1); + assert_eq!(unavailable.summary.cache_estimated_full_cost_amount, None); + sqlx::query("DELETE FROM usage_settlement_snapshots WHERE request_id=$1") + .bind(&request) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM usage WHERE request_id=ANY($1)") + .bind(vec![request, other]) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id=$1") + .bind(&owner) + .execute(&pool) + .await + .unwrap(); +} + +fn assert_dashboard_total_matches_canonical( + total: &UsageAnalyticsMetrics, + canonical: &UsageAnalyticsMetrics, +) { + assert_eq!(total.request_count, canonical.request_count); + assert_eq!(total.total_tokens, canonical.total_tokens); + assert_eq!(total.billable_amount, canonical.billable_amount); + assert_eq!(total.usage_available_count, canonical.usage_available_count); + assert_eq!( + total.pricing_available_count, + canonical.pricing_available_count + ); + assert_eq!(total.settled_count, canonical.settled_count); + assert_eq!( + total.allocation_available_count, + canonical.allocation_available_count + ); +} + +#[tokio::test] +#[ignore = "requires migrated isolated AETHER_TEST_DATABASE_URL"] +async fn live_overview_dashboard_total_matches_canonical_settlement_and_legacy_tokens() { + let pool = sqlx::PgPool::connect(&std::env::var("AETHER_TEST_DATABASE_URL").unwrap()) + .await + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + let user = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO users(id,username,email_verified,is_active) VALUES($1,$1,false,true)") + .bind(&user) + .execute(&mut *tx) + .await + .unwrap(); + let start = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + for (case, api_format, stored_total, metadata, expected_tokens) in [ + ( + "openai-cache", + "openai:chat", + 0_i64, + serde_json::json!({}), + 120_u64, + ), + ( + "anthropic-cache", + "claude:messages", + 0, + serde_json::json!({}), + 138, + ), + ( + "gemini-cache", + "gemini:generateContent", + 0, + serde_json::json!({}), + 120, + ), + ( + "explicit-total", + "claude:messages", + 777, + serde_json::json!({}), + 777, + ), + ( + "snapshot-effective", + "openai:chat", + 777, + serde_json::json!({}), + 27, + ), + ( + "snapshot-context", + "claude:messages", + 777, + serde_json::json!({}), + 1002, + ), + ( + "unavailable", + "openai:chat", + 120, + serde_json::json!({"usage_available":false,"usage_pricing_available":false}), + 0, + ), + ( + "null-metadata", + "openai:chat", + 120, + serde_json::json!(null), + 120, + ), + ( + "array-metadata", + "openai:chat", + 120, + serde_json::json!([]), + 120, + ), + ( + "scalar-metadata", + "openai:chat", + 120, + serde_json::json!(false), + 120, + ), + ( + "string-false", + "openai:chat", + 120, + serde_json::json!({"usage_available":"false","usage_pricing_available":"false"}), + 120, + ), + ( + "session", + "openai:chat", + 120, + serde_json::json!({"analytics_attribution":{"record_kind":"session"}}), + 0, + ), + ( + "standalone", + "openai:chat", + 120, + serde_json::json!({"analytics_attribution":{"is_standalone":true},"analytics_measurement":{"source":"estimated"}}), + 120, + ), + ] { + let request = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,api_format,status,billing_status,input_tokens,output_tokens,total_tokens,cache_creation_input_tokens,cache_creation_input_tokens_5m,cache_creation_input_tokens_1h,cache_read_input_tokens,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES($1,$1,$2,$1,'test',$3,'completed','settled',100,20,$4,0,3,4,11,0.25,0.12500001,$5,$6)") + .bind(&request).bind(&user).bind(api_format).bind(stored_total).bind(start).bind(metadata) + .execute(&mut *tx).await.unwrap(); + if case.starts_with("snapshot-") { + sqlx::query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_effective_input_tokens,billing_total_input_context,billing_output_tokens,billing_cache_creation_5m_tokens,billing_cache_creation_1h_tokens,billing_cache_read_tokens,billing_total_cost_usd,billing_actual_total_cost_usd,allocation_status) VALUES($1,'settled',$2,$3,2,3,5,7,1.00000001,0.90000001,'complete')") + .bind(&request) + .bind((case == "snapshot-effective").then_some(10_i64)) + .bind((case == "snapshot-context").then_some(1000_i64)) + .execute(&mut *tx).await.unwrap(); + } + let query = UsageAnalyticsQuery { + from_unix_ms: start.timestamp_millis() as u64, + to_unix_ms: (start + chrono::Duration::hours(1)).timestamp_millis() as u64, + model: Some(request), + ..Default::default() + }; + let canonical = super::analytics::read_analytics_metrics(&mut tx, &query, false) + .await + .unwrap(); + let total = super::dashboard::read_dashboard_total_metrics(&mut tx, &query, false) + .await + .unwrap(); + assert_eq!(total.total_tokens, expected_tokens, "{case}"); + assert_dashboard_total_matches_canonical(&total, &canonical); + } + tx.rollback().await.unwrap(); +} diff --git a/crates/aether-data/adapters/postgres/src/usage/attribution.rs b/crates/aether-data/adapters/postgres/src/usage/attribution.rs new file mode 100644 index 000000000..e897c131c --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/attribution.rs @@ -0,0 +1,30 @@ +use super::SqlxUsageReadRepository; +use crate::error::SqlxResultExt; +use aether_data_contracts::{repository::usage::UsageAttributionSnapshot, DataLayerError}; + +impl SqlxUsageReadRepository { + pub async fn correct_usage_attribution( + &self, + snapshot: &UsageAttributionSnapshot, + expected_revision: u64, + ) -> Result { + snapshot.validate()?; + if snapshot.attribution_revision <= expected_revision { + return Err(DataLayerError::InvalidInput( + "attribution revision must increase".into(), + )); + } + let result = sqlx::query(r#"UPDATE usage_attribution_snapshots SET actor_user_id=$2, + credential_owner_id=$3,attribution_kind=$4,attribution_source=$5, + record_kind=$6,parent_request_id=$7,schema_version=$8,attribution_revision=$9,recorded_at=NOW() + WHERE request_id=$1 AND attribution_revision=$10 + AND ($2::text IS NULL OR EXISTS (SELECT 1 FROM users WHERE id=$2 AND NOT is_deleted)) + AND ($3::text IS NULL OR EXISTS (SELECT 1 FROM users WHERE id=$3 AND NOT is_deleted))"#) + .bind(&snapshot.request_id).bind(&snapshot.actor_user_id).bind(&snapshot.credential_owner_id) + .bind(&snapshot.attribution_kind).bind(&snapshot.attribution_source) + .bind(&snapshot.record_kind).bind(&snapshot.parent_request_id).bind(snapshot.schema_version as i32) + .bind(snapshot.attribution_revision as i64).bind(expected_revision as i64) + .execute(&self.pool).await.map_postgres_err()?; + Ok(result.rows_affected() == 1) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard.rs new file mode 100644 index 000000000..0c9e260db --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard.rs @@ -0,0 +1,242 @@ +use super::{ + analytics::{dashboard_total_metrics_sql, read_analytics_metrics}, + projection_reader::{read_dashboard_total_summary, read_projection_coverage}, + SqlxUsageReadRepository, +}; +use crate::error::SqlxResultExt; +use aether_data_contracts::{repository::usage::*, DataLayerError}; +use chrono::{DateTime, Utc}; +use sqlx::{Postgres, QueryBuilder, Row}; + +// This projection keeps the canonical billing token precedence while reading usage +// once. Joining the two public fact views reads and hashes the entire usage table +// twice. Extract both availability flags together so large JSON metadata is parsed +// once per row. Live database regressions compare these fields with the canonical view. +const DASHBOARD_TOTAL_FACTS_SQL: &str = r#"( +SELECT u.created_at, u.api_key_id, u.model, u.provider_id, u.api_format, u.endpoint_kind, + u.request_type, u.status, u.is_stream, u.has_format_conversion, u.failure_origin, + 'request'::text AS record_kind, + COALESCE(s.billing_status, u.billing_status) AS settlement_status, + COALESCE(availability.usage_available, 'true'::jsonb) <> 'false'::jsonb AS usage_available, + COALESCE(availability.usage_pricing_available, 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available, + CASE WHEN COALESCE(availability.usage_available, 'true'::jsonb) <> 'false'::jsonb THEN + GREATEST( + COALESCE( + CASE + WHEN s.billing_effective_input_tokens IS NOT NULL + THEN GREATEST(s.billing_effective_input_tokens, 0) + + GREATEST(COALESCE(s.billing_output_tokens, u.output_tokens, 0), 0) + + GREATEST( + COALESCE( + s.billing_cache_creation_tokens, + CASE + WHEN s.billing_cache_creation_5m_tokens IS NOT NULL + OR s.billing_cache_creation_1h_tokens IS NOT NULL + THEN COALESCE(s.billing_cache_creation_5m_tokens, 0) + + COALESCE(s.billing_cache_creation_1h_tokens, 0) + END, + CASE + WHEN COALESCE(u.cache_creation_input_tokens, 0) = 0 + AND ( + COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ) > 0 + THEN COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ELSE COALESCE(u.cache_creation_input_tokens, 0) + END, + 0 + ), + 0 + ) + + GREATEST( + COALESCE( + s.billing_cache_read_tokens, + u.cache_read_input_tokens, + 0 + ), + 0 + ) + WHEN s.billing_total_input_context IS NOT NULL + THEN GREATEST(s.billing_total_input_context, 0) + + GREATEST(COALESCE(s.billing_output_tokens, u.output_tokens, 0), 0) + END, + NULLIF(GREATEST(COALESCE(u.total_tokens, 0), 0), 0), + CASE + WHEN split_part(lower(COALESCE(COALESCE(u.endpoint_api_format, u.api_format), '')), ':', 1) + IN ('openai', 'gemini', 'google') + THEN GREATEST(COALESCE(u.input_tokens, 0), 0) + + GREATEST(COALESCE(u.output_tokens, 0), 0) + ELSE GREATEST(COALESCE(u.input_tokens, 0), 0) + + GREATEST(COALESCE(u.output_tokens, 0), 0) + + GREATEST( + CASE + WHEN COALESCE(u.cache_creation_input_tokens, 0) = 0 + AND ( + COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ) > 0 + THEN COALESCE(u.cache_creation_input_tokens_5m, 0) + + COALESCE(u.cache_creation_input_tokens_1h, 0) + ELSE COALESCE(u.cache_creation_input_tokens, 0) + END, + 0 + ) + + GREATEST(COALESCE(u.cache_read_input_tokens, 0), 0) + END, + 0 + ), + 0 + )::bigint END AS total_tokens, + CASE WHEN COALESCE(availability.usage_pricing_available, 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount, + s.allocation_status +FROM public.usage u +LEFT JOIN public.usage_settlement_snapshots s USING (request_id) +CROSS JOIN LATERAL json_to_record( + CASE WHEN json_typeof(u.request_metadata)='object' THEN u.request_metadata ELSE '{}'::json END +) AS availability(usage_available jsonb, usage_pricing_available jsonb) +WHERE NOT EXISTS (SELECT 1 FROM public.usage_attribution_snapshots a + WHERE a.request_id=u.request_id AND a.record_kind='session') +) AS usage_analytics_facts_v1"#; + +pub(super) fn push_dashboard_total_filter( + builder: &mut QueryBuilder<'_, Postgres>, + query: &UsageAnalyticsQuery, +) { + super::analytics::push_analytics_source_filter(builder, query, DASHBOARD_TOTAL_FACTS_SQL); +} + +/// Lifetime cards only need additive totals and their coverage. Performance and +/// active-user metrics remain exact in the separate today snapshot. +pub(super) async fn read_dashboard_total_metrics( + tx: &mut sqlx::Transaction<'_, Postgres>, + query: &UsageAnalyticsQuery, + projection_reads: bool, +) -> Result { + // A materialized valid-bucket CTE prevents parallel aggregation of raw gaps. + // Upgraded installations can have years of raw history and no built buckets; + // use the parallel raw query directly until there is a bucket to combine. + let has_projection = if projection_reads { + sqlx::query_scalar::<_, bool>(r#"SELECT EXISTS ( + SELECT 1 FROM stats_bucket_state WHERE projection_version='overview-v2' + AND granularity='hour' AND source_revision=built_revision AND coverage_status='complete' + AND NOT EXISTS (SELECT 1 FROM stats_overview_dirty_events e + WHERE e.projection_version=stats_bucket_state.projection_version + AND e.granularity=stats_bucket_state.granularity + AND e.bucket_start=stats_bucket_state.bucket_start) + AND bucket_start = date_trunc('hour', bucket_start AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + AND bucket_start >= $1 AND bucket_start + INTERVAL '1 hour' <= $2 + AND bucket_start + INTERVAL '1 hour' <= NOW())"#) + .bind(DateTime::::from_timestamp_millis(query.from_unix_ms as i64).expect("validated")) + .bind(DateTime::::from_timestamp_millis(query.to_unix_ms as i64).expect("validated")) + .fetch_one(&mut **tx).await.map_postgres_err()? + } else { + false + }; + if has_projection { + return read_dashboard_total_summary(tx, query).await; + } + let mut builder = QueryBuilder::::new("SELECT to_jsonb(m) AS metrics FROM (SELECT "); + builder.push(dashboard_total_metrics_sql()); + push_dashboard_total_filter(&mut builder, query); + builder.push(") m"); + let row = builder + .build() + .fetch_one(&mut **tx) + .await + .map_postgres_err()?; + serde_json::from_value(row.try_get("metrics").map_postgres_err()?) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string())) +} + +impl SqlxUsageReadRepository { + pub async fn query_dashboard_analytics( + &self, + query: &UsageDashboardAnalyticsQuery, + ) -> Result { + query.validate()?; + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout='180s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let state=sqlx::query(r#"SELECT date_trunc('milliseconds',NOW()) AS now,pg_current_snapshot()::text AS revision, + (SELECT min(created_at) FROM usage WHERE created_at0) AS legacy_from, + (SELECT count(*) FROM users WHERE is_active AND NOT is_deleted) AS enabled_users"#) + .fetch_one(&mut *tx).await.map_postgres_err()?; + let now: DateTime = state.try_get("now").map_postgres_err()?; + let raw_from: Option> = state.try_get("raw_from").map_postgres_err()?; + let state_from: Option> = state.try_get("state_from").map_postgres_err()?; + let legacy_from: Option> = state.try_get("legacy_from").map_postgres_err()?; + let total_from = raw_from.into_iter().chain(state_from).min(); + let today_from = query.today_start(now)?; + let revision: String = state.try_get("revision").map_postgres_err()?; + let enabled_users = state + .try_get::("enabled_users") + .map_postgres_err()? as u64; + let mut snapshots = Vec::with_capacity(2); + for (from, lifetime) in [(today_from, false), (total_from.unwrap_or(now), true)] { + let range = UsageAnalyticsQuery { + from_unix_ms: from.timestamp_millis().max(0) as u64, + to_unix_ms: now.timestamp_millis().max(0) as u64, + timezone: query.timezone.clone(), + limit: 1, + ..Default::default() + }; + let mut summary = if lifetime { + read_dashboard_total_metrics(&mut tx, &range, self.overview_projection_reads) + .await? + } else { + read_analytics_metrics(&mut tx, &range, self.overview_projection_reads).await? + }; + summary.enabled_users = enabled_users; + let unrecoverable=sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT bucket_start) FROM (SELECT bucket_start FROM stats_bucket_state WHERE projection_version IN ('overview-v1','overview-v2') AND granularity='hour' AND coverage_status='unrecoverable' UNION SELECT bucket_start FROM stats_overview_dirty_events WHERE projection_version='overview-v2' AND granularity='hour' AND unrecoverable) pending WHERE bucket_start<$2 AND bucket_start+INTERVAL '1 hour'>$1") + .bind(from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64; + if summary.request_count == 0 && unrecoverable == 0 { + summary.rated_amount = Some("0.00000000".into()); + summary.billable_amount = Some("0.00000000".into()); + } + let coverage = + read_projection_coverage(&mut tx, &range, self.overview_projection_reads).await?; + snapshots.push(StoredUsageAnalytics { + total: summary.request_count, + summary, + read_revision: revision.clone(), + generated_at: now.to_rfc3339(), + unrecoverable_bucket_count: unrecoverable, + coverage, + ..Default::default() + }); + } + let mut total = snapshots.pop().expect("total"); + let today = snapshots.pop().expect("today"); + let known_gap = total.unrecoverable_bucket_count > 0 + || legacy_from.is_some_and(|legacy| { + raw_from.is_none_or(|raw| legacy.date_naive() < raw.date_naive()) + }); + if known_gap && total.summary.request_count == 0 { + total.summary.rated_amount = None; + total.summary.billable_amount = None; + } + tx.commit().await.map_postgres_err()?; + Ok(StoredUsageDashboardAnalytics { + today, + total, + today_from: today_from.to_rfc3339(), + total_from: total_from.map(|value| value.to_rfc3339()), + to: now.to_rfc3339(), + history_complete: known_gap.then_some(false), + }) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_retention.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_retention.rs new file mode 100644 index 000000000..739c0c06a --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_retention.rs @@ -0,0 +1,140 @@ +use crate::error::SqlxResultExt; +use crate::DataLayerError; +use chrono::{DateTime, Timelike, Utc}; + +use super::SqlxUsageReadRepository; + +/// Moves expired wide dashboard buckets into the narrow lifetime activity +/// projection and removes data that is no longer needed by the dashboard. +/// Every invocation is bounded; it never scans or rewrites usage history. +impl SqlxUsageReadRepository { + pub async fn maintain_dashboard_projection( + &self, + now: DateTime, + batch_size: i64, + ) -> Result { + let batch_size = batch_size.clamp(1, 5_000); + let cutoff = (now - chrono::Duration::days(35)) + .with_second(0) + .and_then(|value| value.with_nanosecond(0)) + .expect("valid minute"); + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout='5s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL lock_timeout='2s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + + // Always acquire aggregate shards before minute rows, like the deferred + // correction trigger. Cutoff decisions can straddle a minute boundary; + // relying on old/new buckets alone is insufficient to prevent inversion. + // Skip busy shards and keep these locks only for the short compaction tx. + let shards: Vec = sqlx::query_scalar( + "SELECT shard FROM dashboard_stats_total ORDER BY shard FOR UPDATE SKIP LOCKED", + ) + .fetch_all(&mut *tx) + .await + .map_postgres_err()?; + let compacted = sqlx::query( + r#" + WITH selected AS MATERIALIZED ( + SELECT bucket_start, shard, metrics + FROM dashboard_stats_minute WHERE bucket_start < $1 AND shard=ANY($3) + ORDER BY bucket_start, shard LIMIT $2 FOR UPDATE SKIP LOCKED + ), preserved AS ( + INSERT INTO dashboard_activity_minute(bucket_start,shard,request_count) + SELECT bucket_start,shard,COALESCE((metrics->>'request_count')::bigint,0) + FROM selected + ON CONFLICT(bucket_start,shard) DO NOTHING + RETURNING bucket_start + ) + DELETE FROM dashboard_stats_minute wide USING selected + WHERE wide.bucket_start=selected.bucket_start AND wide.shard=selected.shard + AND (SELECT count(*) FROM preserved)>=0 + "#, + ) + .bind(cutoff) + .bind(batch_size) + .bind(&shards) + .execute(&mut *tx) + .await + .map_postgres_err()?; + let mut changed = compacted.rows_affected() > 0; + tx.commit().await.map_postgres_err()?; + + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout='5s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL lock_timeout='2s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + + // Actor counts, user event deltas and per-minute metric details are + // only used by the rolling dashboard window. Their retention is + // intentionally the same as the wide metric buckets. + for table in ["dashboard_actor_minute", "dashboard_user_events_minute"] { + // PostgreSQL has no DELETE ... ORDER BY ... LIMIT syntax. Use a + // bounded key subquery so each pass remains small. + let statement = format!( + "DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table} WHERE bucket_start < $1 ORDER BY bucket_start LIMIT $2)" + ); + let deleted = sqlx::query(&statement) + .bind(cutoff) + .bind(batch_size) + .execute(&mut *tx) + .await + .map_postgres_err()?; + changed |= deleted.rows_affected() > 0; + } + + // Walk the contribution primary key, deleting only rows whose source + // usage no longer exists. Advancing the cursor over live rows avoids + // repeatedly rescanning a large healthy contribution table. + let cursor = sqlx::query_scalar::<_, Option>( + "SELECT contributions_cleanup_cursor FROM dashboard_stats_state WHERE singleton FOR UPDATE", + ) + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + let ids: Vec = match cursor { + Some(cursor) => sqlx::query_scalar( + "SELECT request_id FROM dashboard_request_contributions WHERE request_id > $1 ORDER BY request_id LIMIT $2", + ).bind(cursor).bind(batch_size).fetch_all(&mut *tx).await.map_postgres_err()?, + None => sqlx::query_scalar( + "SELECT request_id FROM dashboard_request_contributions ORDER BY request_id LIMIT $1", + ).bind(batch_size).fetch_all(&mut *tx).await.map_postgres_err()?, + }; + let next_cursor = ids.last().cloned(); + if !ids.is_empty() { + let deleted = sqlx::query( + "DELETE FROM dashboard_request_contributions c\n WHERE c.request_id = ANY($1)\n AND NOT EXISTS (SELECT 1 FROM usage u WHERE u.request_id = c.request_id)", + ) + .bind(&ids) + .execute(&mut *tx) + .await + .map_postgres_err()?; + changed |= deleted.rows_affected() > 0; + } + let cursor_value = if ids.len() < batch_size as usize { + None + } else { + next_cursor + }; + sqlx::query( + "UPDATE dashboard_stats_state SET contributions_cleanup_cursor=$1 WHERE singleton", + ) + .bind(cursor_value) + .execute(&mut *tx) + .await + .map_postgres_err()?; + + tx.commit().await.map_postgres_err()?; + Ok(changed) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs new file mode 100644 index 000000000..1884a1785 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_summary.rs @@ -0,0 +1,162 @@ +use super::SqlxUsageReadRepository; +use crate::error::SqlxResultExt; +use aether_data_contracts::{repository::usage::*, DataLayerError}; +use chrono::{DateTime, NaiveDate, Timelike, Utc}; +use serde_json::Value; +use sqlx::Row; + +fn decode_metrics(mut value: Value) -> Result { + let count = value + .get("request_count") + .and_then(Value::as_u64) + .unwrap_or(0); + let priced = value + .get("pricing_available_count") + .and_then(Value::as_u64) + .unwrap_or(0); + value["billable_amount"] = if count == 0 { + Value::String("0.00000000".into()) + } else if priced == 0 { + Value::Null + } else { + // The SQL converts NUMERIC to text before JSON serialization; no f64 step. + value.get("billable_amount").cloned().unwrap_or(Value::Null) + }; + serde_json::from_value(value) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string())) +} + +impl SqlxUsageReadRepository { + pub async fn query_dashboard_summary( + &self, + query: &UsageDashboardAnalyticsQuery, + ) -> Result { + query.validate()?; + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout='5s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let state = sqlx::query("SELECT stats_since, CURRENT_TIMESTAMP AS now, (SELECT count(*) FROM users WHERE NOT is_deleted) AS users FROM dashboard_stats_state WHERE singleton") + .fetch_one(&mut *tx).await.map_postgres_err()?; + let since: DateTime = state.try_get("stats_since").map_postgres_err()?; + let now: DateTime = state.try_get("now").map_postgres_err()?; + let today_start = query.today_start(now)?; + let today_from = today_start.max(since); + let minute_from = today_from + .with_second(0) + .and_then(|v| v.with_nanosecond(0)) + .expect("valid minute"); + let metrics_rows = sqlx::query(r#" + WITH selected AS ( + SELECT 'total' AS period, metrics FROM dashboard_stats_total + UNION ALL + SELECT 'today', metrics FROM dashboard_stats_minute WHERE bucket_start >= $1 AND bucket_start <= $2 + ), sums AS ( + SELECT period, key, sum(value::numeric) AS amount FROM selected + CROSS JOIN LATERAL jsonb_each_text(metrics) GROUP BY period,key + ) SELECT period, jsonb_object_agg(key, CASE WHEN key='billable_amount' + THEN to_jsonb(round(amount,8)::text) ELSE to_jsonb(amount) END) AS metrics + FROM sums GROUP BY period"#) + .bind(minute_from).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?; + let mut today = decode_metrics(serde_json::json!({}))?; + let mut total = today.clone(); + for row in metrics_rows { + let metrics = decode_metrics(row.try_get("metrics").map_postgres_err()?)?; + match row + .try_get::("period") + .map_postgres_err()? + .as_str() + { + "today" => today = metrics, + _ => total = metrics, + } + } + today.active_users = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT a.actor_user_id) FROM dashboard_actor_minute a JOIN users u ON u.id=a.actor_user_id AND NOT u.is_deleted WHERE a.bucket_start >= $1 AND a.bucket_start <= $2 AND a.request_count > 0") + .bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64; + // Most UTC hours belong to one local day. Only hours straddling a local + // midnight (e.g. Kathmandu) need their minute counts read. Lifetime + // activity therefore scans narrow hourly counters, not metric JSON. + let days = sqlx::query(r#"WITH hours AS MATERIALIZED ( + SELECT bucket_start,shard,request_count, + (bucket_start AT TIME ZONE $1)::date AS day, + ((bucket_start+INTERVAL '1 hour'-INTERVAL '1 microsecond') AT TIME ZONE $1)::date AS last_day + FROM dashboard_activity_hour WHERE request_count>0 AND bucket_start <= $2 + ), daily AS ( + SELECT day, request_count AS requests FROM hours WHERE day=last_day + UNION ALL + SELECT (m.bucket_start AT TIME ZONE $1)::date, m.request_count + FROM hours h JOIN LATERAL ( + SELECT bucket_start,request_count FROM dashboard_activity_minute + WHERE shard=h.shard AND bucket_start>=h.bucket_start + AND bucket_start>'request_count')::bigint,0) + FROM dashboard_stats_minute w + WHERE w.shard=h.shard AND w.bucket_start>=h.bucket_start + AND w.bucket_starth.last_day + ) SELECT day AS date, sum(requests)::bigint AS requests FROM daily + GROUP BY day HAVING sum(requests)>0 ORDER BY day"#) + .bind(&query.timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?; + let local_today = now + .with_timezone( + &query + .timezone + .parse::() + .map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?, + ) + .date_naive(); + let first_heatmap_day = local_today - chrono::Duration::days(364); + let active_days = days.len() as u64; + let dates: Vec = days + .iter() + .map(|row| row.try_get("date").map_postgres_err()) + .collect::>()?; + let consecutive_active_days = + dashboard_consecutive_active_days(dates.iter().copied(), local_today); + let mut activity_days = Vec::new(); + for (row, date) in days.into_iter().zip(dates) { + if date >= first_heatmap_day { + activity_days.push(DashboardActivityDay { + date: date.to_string(), + requests: row.try_get::("requests").map_postgres_err()?.max(0) as u64, + }); + } + } + let events = sqlx::query("SELECT COALESCE(sum(created_count),0)::bigint AS created, COALESCE(sum(deleted_count),0)::bigint AS deleted FROM dashboard_user_events_minute WHERE bucket_start >= $1 AND bucket_start <= $2") + .bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()?; + let summary = StoredDashboardSummary { + stats_since: since.to_rfc3339(), + generated_at: now.to_rfc3339(), + timezone: query.timezone.clone(), + today_from: today_from.to_rfc3339(), + window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0, + today, + total, + users: DashboardUserCounts { + total: state.try_get::("users").map_postgres_err()?.max(0) as u64, + created_today: events + .try_get::("created") + .map_postgres_err()? + .max(0) as u64, + deleted_today: events + .try_get::("deleted") + .map_postgres_err()? + .max(0) as u64, + }, + active_days, + consecutive_active_days, + activity_days, + }; + tx.commit().await.map_postgres_err()?; + Ok(summary) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/dashboard_summary_tests.rs b/crates/aether-data/adapters/postgres/src/usage/dashboard_summary_tests.rs new file mode 100644 index 000000000..e2a577244 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/dashboard_summary_tests.rs @@ -0,0 +1,235 @@ +use super::SqlxUsageReadRepository; +use aether_data_contracts::repository::usage::*; +use chrono::{Duration, Utc}; + +#[tokio::test] +#[ignore = "requires local AETHER_TEST_DATABASE_URL with temporary database creation"] +async fn live_future_dashboard_is_incremental_idempotent_and_survives_retention() { + use futures_util::FutureExt; + use std::panic::AssertUnwindSafe; + let options = std::env::var("AETHER_TEST_DATABASE_URL") + .unwrap() + .parse::() + .unwrap(); + let admin = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options.clone()) + .await + .unwrap(); + let database = format!("future_dashboard_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(&format!("CREATE DATABASE {database}")) + .execute(&admin) + .await + .unwrap(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect_with(options.database(&database)) + .await + .unwrap(); + let outcome = AssertUnwindSafe(async { + crate::POSTGRES_MIGRATOR.run(&pool).await.unwrap(); + let repo = SqlxUsageReadRepository::new(pool.clone()); + let query = UsageDashboardAnalyticsQuery { timezone: "Asia/Kathmandu".into() }; + let empty = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(empty.total.request_count,0); + assert_eq!(empty.consecutive_active_days,0); + assert_eq!(empty.total.billable_amount.as_deref(),Some("0.00000000")); + let since = chrono::DateTime::parse_from_rfc3339(&empty.stats_since).unwrap().with_timezone(&Utc); + sqlx::query("INSERT INTO users(id,username,email_verified,is_active) VALUES('future-user','future-user',false,false)").execute(&pool).await.unwrap(); + let mut tx = pool.begin().await.unwrap(); + for (id,at) in [("old",since-Duration::days(500)),("new",Utc::now()),("second",Utc::now())] { + sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,input_tokens,output_tokens,total_tokens,actual_total_cost_usd,total_cost_usd,created_at,request_metadata,first_byte_time_ms,response_time_ms,upstream_is_stream) VALUES($1,$1,'future-user','m','p','completed','settled',100,20,120,0.25,0.25,$2,'{\"analytics_attribution\":{\"is_standalone\":false},\"upstream_is_stream\":true}',100,800,true)") + .bind(id).bind(at).execute(&mut *tx).await.unwrap(); + } + tx.commit().await.unwrap(); + let first = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(first.total.request_count,2); + assert_eq!(first.total.total_tokens,240); + assert_eq!(first.today.active_users,1); + assert_eq!(first.today.first_byte_sample_count,2); + assert_eq!(first.today.response_sample_count,2); + assert_eq!(first.today.stream_requests,2); + assert_eq!(first.users.total,1,"inactive but nondeleted accounts count"); + assert_eq!(first.users.created_today,1); + assert_eq!(first.active_days,1); + assert_eq!(first.consecutive_active_days,1); + assert_eq!(first.activity_days.iter().map(|d| d.requests).sum::(),2); + sqlx::query("UPDATE usage SET output_tokens=999 WHERE request_id='old'").execute(&pool).await.unwrap(); + let mut rollback = pool.begin().await.unwrap(); + sqlx::query("UPDATE usage SET total_tokens=999 WHERE request_id='second'").execute(&mut *rollback).await.unwrap(); + rollback.rollback().await.unwrap(); + assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,first.total); + sqlx::query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_input_tokens,billing_effective_input_tokens,billing_output_tokens,billing_cache_read_tokens,billing_cache_creation_tokens,billing_total_input_context,billing_total_cost_usd,billing_actual_total_cost_usd) VALUES('new','settled',100,100,30,40,10,150,0.12345678,0.12345678)").execute(&pool).await.unwrap(); + let settled = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(settled.total.request_count,2); + assert_eq!(settled.total.total_tokens,300); + assert_eq!(settled.today.cache_read_tokens,40); + assert_eq!(settled.today.cache_creation_tokens,10); + assert_eq!(settled.today.cache_input_tokens,250); + assert_eq!(settled.total.billable_amount.as_deref(),Some("0.37345678")); + sqlx::query("UPDATE usage_settlement_snapshots SET billing_actual_total_cost_usd=0.12345678 WHERE request_id='new'").execute(&pool).await.unwrap(); + assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,settled.total); + // Independent usage and settlement transactions converge on the final + // canonical fact regardless of which deferred projection acquires its shard first. + let usage_update = async { + let mut tx = pool.begin().await.unwrap(); + sqlx::query("UPDATE usage SET response_time_ms=900 WHERE request_id='new'") + .execute(&mut *tx).await.unwrap(); + tx.commit().await.unwrap(); + }; + let settlement_update = async { + let mut tx = pool.begin().await.unwrap(); + sqlx::query("UPDATE usage_settlement_snapshots SET billing_output_tokens=40 WHERE request_id='new'") + .execute(&mut *tx).await.unwrap(); + tx.commit().await.unwrap(); + }; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!(usage_update,settlement_update); + }).await.expect("concurrent writes finish without deadlock"); + let concurrent = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(concurrent.total.total_tokens,310); + assert_eq!(concurrent.today.response_sum_ms,1700.0); + // A correction followed by purge in one transaction captures the final fact. + let mut purge = pool.begin().await.unwrap(); + sqlx::query("UPDATE usage_settlement_snapshots SET billing_output_tokens=50 WHERE request_id='new'").execute(&mut *purge).await.unwrap(); + sqlx::query("DELETE FROM usage WHERE request_id='new'").execute(&mut *purge).await.unwrap(); + purge.commit().await.unwrap(); + let retained = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(retained.total.request_count,2); + assert_eq!(retained.total.total_tokens,320); + assert_eq!(retained.total.billable_amount,settled.total.billable_amount); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id='new'").fetch_one(&pool).await.unwrap(),0,"purged requests retain totals without retaining correction ledgers"); + sqlx::query("UPDATE users SET is_deleted=true WHERE id='future-user'").execute(&pool).await.unwrap(); + sqlx::query("DELETE FROM users WHERE id='future-user'").execute(&pool).await.unwrap(); + let deleted = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(deleted.users.total,0); + assert_eq!(deleted.users.deleted_today,1,"soft deletion then purge counts one removal"); + assert_eq!(deleted.total.request_count,2); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_stats_pending").fetch_one(&pool).await.unwrap(),0); + // Kathmandu midnight splits a UTC hour. Only that hour's minute + // counters must supply the two separate local activity dates. + let midnight = query.today_start(Utc::now()).unwrap()-Duration::days(1); + sqlx::query("UPDATE dashboard_stats_state SET stats_since=$1") + .bind(midnight-Duration::minutes(2)).execute(&pool).await.unwrap(); + for (id, at) in [("before-midnight",midnight-Duration::minutes(1)),("after-midnight",midnight+Duration::minutes(1))] { + sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,created_at) VALUES($1,$1,'m','p','completed','pending',$2)") + .bind(id).bind(at).execute(&pool).await.unwrap(); + } + let boundary = repo.query_dashboard_summary(&query).await.unwrap(); + let tz: chrono_tz::Tz = query.timezone.parse().unwrap(); + for at in [midnight-Duration::minutes(1),midnight+Duration::minutes(1)] { + let day = at.with_timezone(&tz).date_naive().to_string(); + assert_eq!(boundary.activity_days.iter().find(|entry| entry.date==day).unwrap().requests,1); + } + assert_eq!(boundary.active_days,3); + assert_eq!(boundary.consecutive_active_days,3); + assert_eq!(boundary.total.request_count,4); + + // Upgrade compatibility: old installations contain wide minutes but + // no narrow activity minutes. Keep local-calendar history accurate + // during partial compaction, including midnight splitting a UTC hour. + let historic_midnight = query.today_start(Utc::now()).unwrap()-Duration::days(40); + sqlx::query("UPDATE dashboard_stats_state SET stats_since=$1") + .bind(historic_midnight-Duration::days(1)).execute(&pool).await.unwrap(); + for (id,at) in [("historic-before",historic_midnight-Duration::minutes(1)),("historic-after",historic_midnight+Duration::minutes(1))] { + sqlx::query("INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,created_at,response_time_ms) VALUES($1,$1,'retained-user','m','p','completed','pending',$2,10)") + .bind(id).bind(at).execute(&pool).await.unwrap(); + } + sqlx::query("INSERT INTO dashboard_stats_minute(bucket_start,shard,metrics) SELECT date_trunc('minute',created_at),(hashtextextended(request_id,0)&15)::smallint,metrics FROM dashboard_request_contributions WHERE request_id LIKE 'historic-%'").execute(&pool).await.unwrap(); + sqlx::query("DELETE FROM dashboard_activity_minute WHERE bucket_start < now()-INTERVAL '35 days'").execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO dashboard_actor_minute(bucket_start,shard,actor_user_id,request_count) VALUES($1,0,'old-actor',1)").bind(historic_midnight).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO dashboard_user_events_minute(bucket_start,shard,created_count) VALUES($1,0,1)").bind(historic_midnight).execute(&pool).await.unwrap(); + let legacy = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(legacy.active_days,boundary.active_days+2); + assert_eq!(legacy.total.request_count,boundary.total.request_count+2); + // A delayed correction seeds the old narrow minute before applying its + // delta; a later compaction must not overwrite or double its count. + sqlx::query("UPDATE usage SET response_time_ms=20 WHERE request_id='historic-before'").execute(&pool).await.unwrap(); + let corrected = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(corrected.activity_days,legacy.activity_days); + repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap(); + assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().activity_days,legacy.activity_days,"partially compacted history remains complete"); + let old_correction = async { + sqlx::query("UPDATE usage SET response_time_ms=40 WHERE request_id='historic-after'").execute(&pool).await.unwrap(); + }; + let compaction = async { + repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap(); + }; + tokio::time::timeout(std::time::Duration::from_secs(5),async { + tokio::join!(old_correction,compaction); + }).await.expect("old corrections and retention use compatible lock orders"); + // A busy shard is deliberately deferred to the next maintenance pass. + repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_stats_minute WHERE bucket_start < now()-INTERVAL '35 days'").fetch_one(&pool).await.unwrap(),0); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_actor_minute WHERE bucket_start < now()-INTERVAL '35 days'").fetch_one(&pool).await.unwrap(),0); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_user_events_minute WHERE bucket_start < now()-INTERVAL '35 days'").fetch_one(&pool).await.unwrap(),0); + let compacted = repo.query_dashboard_summary(&query).await.unwrap(); + let mut expected_total = corrected.total.clone(); + expected_total.response_sum_ms += 30.0; + assert_eq!(compacted.total,expected_total); + assert_eq!(compacted.activity_days,legacy.activity_days); + assert_eq!(compacted.consecutive_active_days,legacy.consecutive_active_days); + sqlx::query("DELETE FROM usage WHERE request_id LIKE 'historic-%'").execute(&pool).await.unwrap(); + let purged_history = repo.query_dashboard_summary(&query).await.unwrap(); + assert_eq!(purged_history.total,compacted.total); + assert_eq!(purged_history.activity_days,compacted.activity_days); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id LIKE 'historic-%'").fetch_one(&pool).await.unwrap(),0); + + // Orphan ledgers left by the old trigger are removed in bounded PK + // pages. Live rows before the orphans must not starve later pages. + for id in ["zz-orphan-1","zz-orphan-2","zz-orphan-3"] { + sqlx::query("INSERT INTO dashboard_request_contributions(request_id,created_at,metrics) VALUES($1,now(),'{}')").bind(id).execute(&pool).await.unwrap(); + } + sqlx::query("UPDATE dashboard_stats_state SET contributions_cleanup_cursor=NULL").execute(&pool).await.unwrap(); + repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id LIKE 'zz-orphan-%'").fetch_one(&pool).await.unwrap(),3); + for _ in 0..12 { repo.maintain_dashboard_projection(Utc::now(),1).await.unwrap(); } + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_request_contributions WHERE request_id LIKE 'zz-orphan-%'").fetch_one(&pool).await.unwrap(),0); + assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,purged_history.total); + + // A correction may decide a bucket is still inside retention while a + // concurrent cleaner crosses the next minute cutoff. Hold its narrow + // row to suspend that correction after it locks the total shard, then + // prove maintenance skips the shard instead of locking wide and waiting + // on narrow (which would invert the correction's lock order). + let edge_now=Utc::now(); + let edge_at=edge_now-Duration::days(35)+Duration::minutes(1); + sqlx::query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,created_at,response_time_ms) VALUES('retention-edge','retention-edge','m','p','completed','pending',$1,10)") + .bind(edge_at).execute(&pool).await.unwrap(); + let edge_shard:i16=sqlx::query_scalar("SELECT (hashtextextended('retention-edge',0)&15)::smallint").fetch_one(&pool).await.unwrap(); + let mut blocker=pool.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM dashboard_activity_minute WHERE bucket_start=date_trunc('minute',$1::timestamptz) AND shard=$2 FOR UPDATE") + .bind(edge_at).bind(edge_shard).execute(&mut *blocker).await.unwrap(); + let correction_pool=pool.clone(); + let edge_correction=tokio::spawn(async move { + sqlx::query("UPDATE usage SET response_time_ms=30 WHERE request_id='retention-edge'") + .execute(&correction_pool).await.unwrap(); + }); + tokio::time::timeout(std::time::Duration::from_secs(2),async { + loop { + let available=sqlx::query_scalar::<_,i16>("SELECT shard FROM dashboard_stats_total WHERE shard=$1 FOR UPDATE SKIP LOCKED") + .bind(edge_shard).fetch_optional(&pool).await.unwrap(); + if available.is_none() { break; } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }).await.expect("correction reached its shard lock"); + tokio::time::timeout(std::time::Duration::from_secs(1),repo.maintain_dashboard_projection(edge_now+Duration::minutes(2),1000)) + .await.expect("retention skips a correction's busy shard at the cutoff boundary").unwrap(); + blocker.rollback().await.unwrap(); + edge_correction.await.unwrap(); + let edge_corrected=repo.query_dashboard_summary(&query).await.unwrap(); + repo.maintain_dashboard_projection(edge_now+Duration::minutes(2),1000).await.unwrap(); + assert_eq!(repo.query_dashboard_summary(&query).await.unwrap().total,edge_corrected.total); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT count(*) FROM dashboard_stats_minute WHERE bucket_start=date_trunc('minute',$1::timestamptz) AND shard=$2") + .bind(edge_at).bind(edge_shard).fetch_one(&pool).await.unwrap(),0); + }).catch_unwind().await; + pool.close().await; + sqlx::query(&format!("DROP DATABASE {database} WITH (FORCE)")) + .execute(&admin) + .await + .unwrap(); + admin.close().await; + if let Err(error) = outcome { + std::panic::resume_unwind(error); + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/health.rs b/crates/aether-data/adapters/postgres/src/usage/health.rs new file mode 100644 index 000000000..47c529e4c --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/health.rs @@ -0,0 +1,149 @@ +use super::SqlxUsageReadRepository; +use crate::error::SqlxResultExt; +use aether_data_contracts::repository::usage::*; +use aether_data_contracts::DataLayerError; +use chrono::{DateTime, Utc}; +use sqlx::Row; +use std::collections::BTreeMap; + +impl SqlxUsageReadRepository { + pub async fn summarize_health_observations( + &self, + query: &HealthObservationQuery, + ) -> Result { + if query.from_unix_ms >= query.to_unix_ms + || query.to_unix_ms - query.from_unix_ms > 31 * 86_400_000 + || query.segments == 0 + || query.segments > 96 + || query.to_unix_ms > 253_402_300_799_000 + { + return Err(DataLayerError::InvalidInput( + "invalid health observation window".into(), + )); + } + let (request_object, attempt_object) = match query.object_kind { + HealthObservationObjectKind::ApiFormat => ("u.api_format", "u.api_format"), + HealthObservationObjectKind::Model => ("u.model", "u.model"), + HealthObservationObjectKind::Provider => ("u.provider_id", "c.provider_id"), + }; + let sql = format!( + r#" +WITH observations AS ( + SELECT {request_object}::text AS object_value, u.created_at AS event_at, 'request' AS kind, + u.status, u.failure_origin, u.failure_stage, u.failure_reason, u.response_time_ms + FROM usage_analytics_facts_v1 u WHERE u.created_at >= $1 AND u.created_at < $2 AND u.record_kind <> 'session' + UNION ALL + SELECT {attempt_object}::text AS object_value, c.created_at AS event_at, 'attempt' AS kind, + c.status::text, NULL, NULL, NULL, NULL::bigint + FROM request_candidates c LEFT JOIN usage_analytics_facts_v1 u ON u.request_id = c.request_id + WHERE c.created_at >= $1 AND c.created_at < $2 + AND (c.status IN ('streaming','success','failed','cancelled') OR (c.status = 'pending' AND c.started_at IS NOT NULL)) +), scoped AS ( + SELECT *, LEAST($4 - 1, FLOOR(EXTRACT(EPOCH FROM (event_at - $1)) * 1000 / $5)::integer) AS segment, + (status IN ('failed','cancelled') AND ((status = 'cancelled' AND failure_origin = 'client') OR (failure_origin = 'client' AND + (failure_stage IN ('authentication','admission') OR failure_reason IN ('invalid_input','invalid_credentials','quota_exceeded','policy_rejection'))))) IS TRUE AS excluded + FROM observations WHERE object_value IS NOT NULL AND ($3::text[] IS NULL OR object_value = ANY($3)) +), aggregates AS ( + SELECT object_value, segment, GROUPING(object_value) AS all_objects, GROUPING(segment) AS all_segments, + count(*) FILTER (WHERE kind = 'request')::bigint AS request_count, + count(*) FILTER (WHERE kind = 'request' AND status = 'completed')::bigint AS succeeded_count, + count(*) FILTER (WHERE kind = 'request' AND status = 'failed')::bigint AS failed_count, + count(*) FILTER (WHERE kind = 'request' AND status NOT IN ('completed','failed','cancelled'))::bigint AS in_progress_count, + count(*) FILTER (WHERE kind = 'request' AND status = 'cancelled')::bigint AS cancelled_count, + count(*) FILTER (WHERE kind = 'request' AND status = 'completed')::bigint AS service_succeeded_count, + count(*) FILTER (WHERE kind = 'request' AND status IN ('failed','cancelled') AND NOT excluded AND failure_origin IN ('gateway','upstream','transport'))::bigint AS service_failed_count, + count(*) FILTER (WHERE kind = 'request' AND excluded)::bigint AS excluded_count, + count(*) FILTER (WHERE kind = 'request' AND status IN ('failed','cancelled') AND NOT excluded AND (failure_origin IS NULL OR failure_origin NOT IN ('gateway','upstream','transport')))::bigint AS unknown_failure_count, + count(*) FILTER (WHERE kind = 'attempt' AND status = 'success')::bigint AS attempt_succeeded_count, + count(*) FILTER (WHERE kind = 'attempt' AND status = 'failed')::bigint AS attempt_failed_count, + count(*) FILTER (WHERE kind = 'attempt' AND status IN ('pending','streaming'))::bigint AS attempt_in_progress_count, + count(*) FILTER (WHERE kind = 'attempt' AND status = 'cancelled')::bigint AS attempt_cancelled_count, + COALESCE(sum(response_time_ms) FILTER (WHERE kind = 'request'),0)::double precision AS latency_sum_ms, + count(response_time_ms) FILTER (WHERE kind = 'request')::bigint AS latency_sample_count, + (EXTRACT(EPOCH FROM max(event_at) FILTER (WHERE kind = 'request')) * 1000)::bigint AS last_request_at_unix_ms + FROM scoped GROUP BY GROUPING SETS ((object_value,segment),(object_value),(segment),()) +) SELECT object_value, segment, all_objects, all_segments, + to_jsonb(aggregates) - 'object_value' - 'segment' - 'all_objects' - 'all_segments' AS metrics FROM aggregates +"# + ); + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout = '15s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let width = (query.to_unix_ms - query.from_unix_ms).div_ceil(u64::from(query.segments)); + let rows = sqlx::query(&sql) + .bind( + DateTime::::from_timestamp_millis(query.from_unix_ms as i64) + .expect("validated timestamp"), + ) + .bind( + DateTime::::from_timestamp_millis(query.to_unix_ms as i64) + .expect("validated timestamp"), + ) + .bind(query.object_values.as_ref()) + .bind(query.segments as i32) + .bind(width as f64) + .fetch_all(&mut *tx) + .await + .map_postgres_err()?; + let make_timeline = || { + (0..query.segments) + .filter_map(|segment| { + let from = query.from_unix_ms + u64::from(segment) * width; + (from < query.to_unix_ms).then(|| HealthObservationBucket { + from_unix_ms: from, + to_unix_ms: (from + width).min(query.to_unix_ms), + metrics: Default::default(), + }) + }) + .collect::>() + }; + let mut result = HealthObservationSummary { + timeline: make_timeline(), + ..Default::default() + }; + let mut objects: BTreeMap = BTreeMap::new(); + for row in rows { + let metrics: HealthObservationMetrics = + serde_json::from_value(row.try_get("metrics").map_postgres_err()?) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?; + let all_objects: i32 = row.try_get("all_objects").map_postgres_err()?; + let all_segments: i32 = row.try_get("all_segments").map_postgres_err()?; + let segment: Option = row.try_get("segment").map_postgres_err()?; + if all_objects == 1 { + if all_segments == 1 { + result.overall = metrics; + } else if let Some(bucket) = + segment.and_then(|segment| result.timeline.get_mut(segment as usize)) + { + bucket.metrics = metrics; + } + } else { + let value: String = row.try_get("object_value").map_postgres_err()?; + let object = + objects + .entry(value.clone()) + .or_insert_with(|| HealthObservationObject { + object_value: value, + metrics: Default::default(), + timeline: make_timeline(), + }); + if all_segments == 1 { + object.metrics = metrics; + } else if let Some(bucket) = + segment.and_then(|segment| object.timeline.get_mut(segment as usize)) + { + bucket.metrics = metrics; + } + } + } + result.objects = objects.into_values().collect(); + tx.commit().await.map_postgres_err()?; + Ok(result) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/mod.rs b/crates/aether-data/adapters/postgres/src/usage/mod.rs index 23f7615d3..e1b9605b8 100644 --- a/crates/aether-data/adapters/postgres/src/usage/mod.rs +++ b/crates/aether-data/adapters/postgres/src/usage/mod.rs @@ -57,8 +57,20 @@ use aether_data_contracts::repository::usage::{ }; use aether_data_contracts::DataLayerError; +mod analytics; +#[cfg(test)] +mod analytics_tests; +mod attribution; pub mod cleanup; +mod dashboard; +mod dashboard_summary; +mod dashboard_retention; +#[cfg(test)] +mod dashboard_summary_tests; +mod health; +mod overview_buckets; mod preparation; +mod projection_reader; use preparation::prepare_usage_in_background; @@ -70,6 +82,62 @@ const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str = r#"SELECT CASE WHEN octet_length(p const DELETE_USAGE_BODY_BLOB_SQL: &str = include_str!("queries/delete_usage_body_blob_sql.sql"); static USAGE_BODY_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); +fn push_usage_analytics_drilldown( + builder: &mut QueryBuilder<'_, Postgres>, + has_where: &mut bool, + provider_id: &Option, + api_key_id: &Option, + request_id: &Option, + attribution_kind: &Option, + actor_user_id: &Option, + endpoint_kind: &Option, + request_type: &Option, + slow_threshold_ms: Option, + has_format_conversion: Option, +) { + for (column, value) in [ + ("provider_id", provider_id), + ("api_key_id", api_key_id), + ("request_id", request_id), + ("endpoint_kind", endpoint_kind), + ("request_type", request_type), + ] { + if let Some(value) = value { + builder.push(if *has_where { " AND " } else { " WHERE " }); + *has_where = true; + builder + .push("\"usage\".") + .push(column) + .push(" = ") + .push_bind(value.clone()); + } + } + for (column, value) in [ + ("attribution_kind", attribution_kind), + ("actor_user_id", actor_user_id), + ] { + if let Some(value) = value { + builder.push(if *has_where { " AND " } else { " WHERE " }); + *has_where = true; + builder.push("EXISTS(SELECT 1 FROM usage_analytics_facts_v1 f WHERE f.request_id=\"usage\".request_id AND f.").push(column).push(" = ").push_bind(value.clone()).push(")"); + } + } + if let Some(value) = slow_threshold_ms { + builder.push(if *has_where { " AND " } else { " WHERE " }); + *has_where = true; + builder + .push("\"usage\".response_time_ms >= ") + .push_bind(i64::try_from(value).unwrap_or(i64::MAX)); + } + if let Some(value) = has_format_conversion { + builder.push(if *has_where { " AND " } else { " WHERE " }); + *has_where = true; + builder + .push("\"usage\".has_format_conversion = ") + .push_bind(value); + } +} + async fn decode_usage_body_in_background( decode: impl FnOnce() -> Result, DataLayerError> + Send + 'static, ) -> Result, DataLayerError> { @@ -2073,6 +2141,7 @@ WHERE request_id = $1 pub struct SqlxUsageReadRepository { pool: PgPool, tx_runner: PostgresTransactionRunner, + overview_projection_reads: bool, } #[derive(Debug)] @@ -2301,7 +2370,23 @@ fn partition_first_byte_usages( impl SqlxUsageReadRepository { pub fn new(pool: PgPool) -> Self { let tx_runner = PostgresTransactionRunner::new(pool.clone()); - Self { pool, tx_runner } + let overview_projection_reads = !std::env::var("AETHER_OVERVIEW_PROJECTION_READS") + .is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "false" | "0" | "off" + ) + }); + Self { + pool, + tx_runner, + overview_projection_reads, + } + } + + pub fn with_overview_projection_reads(mut self, enabled: bool) -> Self { + self.overview_projection_reads = enabled; + self } pub fn pool(&self) -> &PgPool { @@ -3053,6 +3138,19 @@ ORDER BY request_count DESC, "usage".provider_name ASC .push_bind(created_until_unix_secs as f64) .push("::double precision)"); } + push_usage_analytics_drilldown( + &mut builder, + &mut has_where, + &query.provider_id, + &query.api_key_id, + &query.request_id, + &query.attribution_kind, + &query.actor_user_id, + &query.endpoint_kind, + &query.request_type, + query.slow_threshold_ms, + query.has_format_conversion, + ); if let Some(user_id) = query.user_id.as_deref() { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -3166,6 +3264,19 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> '' .push_bind(created_until_unix_secs as f64) .push("::double precision)"); } + push_usage_analytics_drilldown( + &mut builder, + &mut has_where, + &query.provider_id, + &query.api_key_id, + &query.request_id, + &query.attribution_kind, + &query.actor_user_id, + &query.endpoint_kind, + &query.request_type, + query.slow_threshold_ms, + query.has_format_conversion, + ); if let Some(user_id) = query.user_id.as_deref() { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -3360,6 +3471,19 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> '' .push_bind(created_until_unix_secs as f64) .push("::double precision)"); } + push_usage_analytics_drilldown( + &mut builder, + &mut has_where, + &query.provider_id, + &query.api_key_id, + &query.request_id, + &query.attribution_kind, + &query.actor_user_id, + &query.endpoint_kind, + &query.request_type, + query.slow_threshold_ms, + query.has_format_conversion, + ); if let Some(user_id) = query.user_id.as_deref() { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -3462,6 +3586,19 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> '' .push_bind(created_until_unix_secs as f64) .push("::double precision)"); } + push_usage_analytics_drilldown( + &mut builder, + &mut has_where, + &query.provider_id, + &query.api_key_id, + &query.request_id, + &query.attribution_kind, + &query.actor_user_id, + &query.endpoint_kind, + &query.request_type, + query.slow_threshold_ms, + query.has_format_conversion, + ); if let Some(user_id) = query.user_id.as_deref() { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -10340,6 +10477,39 @@ RETURNING #[async_trait] impl UsageReadRepository for SqlxUsageReadRepository { + async fn query_dashboard_summary( + &self, + query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery, + ) -> Result { + Self::query_dashboard_summary(self, query).await + } + + async fn query_dashboard_analytics( + &self, + query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery, + ) -> Result< + aether_data_contracts::repository::usage::StoredUsageDashboardAnalytics, + DataLayerError, + > { + Self::query_dashboard_analytics(self, query).await + } + + async fn summarize_health_observations( + &self, + query: &aether_data_contracts::repository::usage::HealthObservationQuery, + ) -> Result + { + SqlxUsageReadRepository::summarize_health_observations(self, query).await + } + + async fn query_usage_analytics( + &self, + query: &aether_data_contracts::repository::usage::UsageAnalyticsQuery, + ) -> Result + { + SqlxUsageReadRepository::query_usage_analytics(self, query).await + } + async fn find_by_id( &self, id: &str, diff --git a/crates/aether-data/adapters/postgres/src/usage/overview_buckets.rs b/crates/aether-data/adapters/postgres/src/usage/overview_buckets.rs new file mode 100644 index 000000000..648f24d96 --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/overview_buckets.rs @@ -0,0 +1,203 @@ +use super::{analytics::analytics_additive_metrics_sql, SqlxUsageReadRepository}; +use crate::error::SqlxResultExt; +use aether_data_contracts::DataLayerError; +use chrono::{DateTime, Utc}; +use sqlx::Row; + +impl SqlxUsageReadRepository { + /// Move committed writer-owned events into bucket state in one transaction. + /// Readers exclude both queued and merged dirtiness in their fact snapshot. + /// Never acknowledge a high-water mark: transaction IDs can commit out of order. + pub async fn merge_overview_dirty_events(&self) -> Result { + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout = '15s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let acquired: bool = sqlx::query_scalar( + "SELECT pg_try_advisory_xact_lock(hashtextextended('overview-dirty-merge', 19))", + ) + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + if !acquired { + tx.rollback().await.map_postgres_err()?; + return Ok(0); + } + let count: i64 = sqlx::query_scalar( + r#"WITH selected AS MATERIALIZED ( + SELECT transaction_id,projection_version,granularity,bucket_start + FROM stats_overview_dirty_events + WHERE projection_version='overview-v2' + ORDER BY transaction_id,projection_version,granularity,bucket_start + LIMIT 10000 FOR UPDATE SKIP LOCKED + ), removed AS ( + DELETE FROM stats_overview_dirty_events e USING selected s + WHERE (e.transaction_id,e.projection_version,e.granularity,e.bucket_start) + = (s.transaction_id,s.projection_version,s.granularity,s.bucket_start) + RETURNING e.* + ), merged AS ( + INSERT INTO stats_bucket_state(projection_version,granularity,bucket_start, + source_revision,coverage_status,last_error) + SELECT projection_version,granularity,bucket_start,count(*), + CASE WHEN bool_or(unrecoverable) THEN 'unrecoverable' ELSE 'unbuilt' END, + CASE WHEN bool_or(unrecoverable) THEN 'retained usage facts were deleted' END + FROM removed GROUP BY projection_version,granularity,bucket_start + ON CONFLICT (projection_version,granularity,bucket_start) DO UPDATE SET + source_revision=stats_bucket_state.source_revision+EXCLUDED.source_revision, + coverage_status=CASE WHEN EXCLUDED.coverage_status='unrecoverable' + THEN 'unrecoverable' ELSE stats_bucket_state.coverage_status END, + last_error=CASE WHEN EXCLUDED.coverage_status='unrecoverable' + THEN EXCLUDED.last_error ELSE stats_bucket_state.last_error END + RETURNING 1 + ) SELECT count(*) FROM removed"#, + ) + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + tx.commit().await.map_postgres_err()?; + Ok(count as u64) + } + + /// Rebuild buckets marked dirty by normal writes without backfilling historical facts. + pub async fn rebuild_overview_buckets( + &self, + target: DateTime, + budget: usize, + ) -> Result { + let budget = budget.min(48); + if budget == 0 { + return Ok(0); + } + let merged_events = self.merge_overview_dirty_events().await?; + let rows = sqlx::query( + r#"SELECT granularity,bucket_start FROM stats_bucket_state + WHERE projection_version='overview-v2' AND source_revision > built_revision + AND source_revision > 0 + AND coverage_status <> 'unrecoverable' + AND (last_failed_at IS NULL OR last_failed_at < NOW() - INTERVAL '10 minutes') + AND bucket_start + ('1 ' || granularity)::interval <= $1 + ORDER BY last_failed_at NULLS FIRST,bucket_start,granularity LIMIT $2"#, + ) + .bind(target) + .bind(budget as i64) + .fetch_all(&self.pool) + .await + .map_postgres_err()?; + let mut published = 0; + for row in rows { + let granularity: String = row.try_get("granularity").map_postgres_err()?; + let bucket: DateTime = row.try_get("bucket_start").map_postgres_err()?; + match self + .rebuild_merged_overview_bucket(&granularity, bucket) + .await + { + Ok(true) => published += 1, + Ok(false) => {} + Err(error) => { + sqlx::query("UPDATE stats_bucket_state SET last_error=$3,last_failed_at=NOW() WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2") + .bind(&granularity).bind(bucket).bind(error.to_string().chars().take(500).collect::()) + .execute(&self.pool).await.map_postgres_err()?; + } + } + } + // A busy current hour has no closed bucket to publish yet. Still report + // progress so the bounded maintenance catch-up loop drains the next batch. + Ok(published.max(usize::from(merged_events > 0))) + } + + #[cfg(test)] + pub(super) async fn rebuild_overview_bucket( + &self, + granularity: &str, + bucket: DateTime, + ) -> Result { + self.merge_overview_dirty_events().await?; + self.rebuild_merged_overview_bucket(granularity, bucket) + .await + } + + async fn rebuild_merged_overview_bucket( + &self, + granularity: &str, + bucket: DateTime, + ) -> Result { + let (table, duration) = match granularity { + "hour" => ("stats_overview_hourly", chrono::Duration::hours(1)), + "day" => ("stats_overview_daily", chrono::Duration::days(1)), + _ => { + return Err(DataLayerError::InvalidInput( + "invalid overview bucket granularity".into(), + )); + } + }; + let mut tx = self.pool.begin().await.map_postgres_err()?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL statement_timeout = '15s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + sqlx::query("SET LOCAL lock_timeout = '1s'") + .execute(&mut *tx) + .await + .map_postgres_err()?; + let acquired: bool = + sqlx::query_scalar("SELECT pg_try_advisory_xact_lock(hashtextextended($1, 19))") + .bind(format!("overview-v2:{granularity}:{}", bucket.timestamp())) + .fetch_one(&mut *tx) + .await + .map_postgres_err()?; + if !acquired { + tx.rollback().await.map_postgres_err()?; + return Ok(false); + } + let revision: Option = sqlx::query_scalar("SELECT source_revision FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2") + .bind(granularity).bind(bucket).fetch_optional(&mut *tx).await.map_postgres_err()?; + let Some(revision) = revision else { + // Another merger may be running, or a bounded batch has not reached this bucket yet. + tx.rollback().await.map_postgres_err()?; + return Ok(false); + }; + let lost: bool = sqlx::query_scalar("SELECT coverage_status='unrecoverable' FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2") + .bind(granularity).bind(bucket).fetch_one(&mut *tx).await.map_postgres_err()?; + if lost { + tx.rollback().await.map_postgres_err()?; + return Ok(false); + } + sqlx::query(&format!( + "DELETE FROM {table} WHERE projection_version='overview-v2' AND bucket_start=$1" + )) + .bind(bucket) + .execute(&mut *tx) + .await + .map_postgres_err()?; + let sql = format!( + r#"INSERT INTO {table}(projection_version,bucket_start,dimensions,metrics) + SELECT 'overview-v2',$1,dimensions,to_jsonb(m) - 'dimensions' + FROM (SELECT dimensions, {} FROM ( + SELECT *, jsonb_build_object('attribution_kind',attribution_kind,'actor_user_id',actor_user_id, + 'credential_owner_id',credential_owner_id,'api_key_id',api_key_id,'model',model, + 'provider_id',provider_id,'api_format',api_format,'request_type',request_type,'record_kind',record_kind) dimensions + FROM usage_analytics_facts_v1 WHERE created_at >= $1 AND created_at < $2 AND record_kind <> 'session' + ) facts GROUP BY dimensions) m"#, + analytics_additive_metrics_sql(5000) + ); + sqlx::query(&sql) + .bind(bucket) + .bind(bucket + duration) + .execute(&mut *tx) + .await + .map_postgres_err()?; + let published = sqlx::query("UPDATE stats_bucket_state SET built_revision=$3,coverage_status='complete',built_at=NOW(),last_error=NULL,last_failed_at=NULL WHERE projection_version='overview-v2' AND granularity=$1 AND bucket_start=$2 AND source_revision=$3") + .bind(granularity).bind(bucket).bind(revision).execute(&mut *tx).await.map_postgres_err()?.rows_affected(); + if published != 1 { + tx.rollback().await.map_postgres_err()?; + return Ok(false); + } + tx.commit().await.map_postgres_err()?; + Ok(true) + } +} diff --git a/crates/aether-data/adapters/postgres/src/usage/projection_reader.rs b/crates/aether-data/adapters/postgres/src/usage/projection_reader.rs new file mode 100644 index 000000000..e9db69c6b --- /dev/null +++ b/crates/aether-data/adapters/postgres/src/usage/projection_reader.rs @@ -0,0 +1,232 @@ +use super::analytics::{ + analytics_additive_metrics_sql, dashboard_total_metrics_sql, push_analytics_filter, +}; +use crate::error::SqlxResultExt; +use aether_data_contracts::{DataLayerError, repository::usage::*}; +use chrono::{DateTime, Utc}; +use sqlx::{Postgres, QueryBuilder, Row}; + +pub(super) async fn read_projection_coverage( + tx: &mut sqlx::Transaction<'_, Postgres>, + query: &UsageAnalyticsQuery, + read_enabled: bool, +) -> Result { + let row = sqlx::query(r#"WITH rounded AS ( + SELECT date_trunc('hour',$1::timestamptz AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS start_at, + date_trunc('hour',LEAST($2::timestamptz,NOW()) AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS end_at + ), bounds AS ( + SELECT CASE WHEN start_at<$1 THEN start_at+INTERVAL '1 hour' ELSE start_at END AS start_at,end_at FROM rounded + ), pending AS ( + SELECT e.bucket_start,bool_or(e.unrecoverable) AS unrecoverable + FROM stats_overview_dirty_events e CROSS JOIN bounds b + WHERE e.projection_version='overview-v2' AND e.granularity='hour' + AND e.bucket_start>=b.start_at AND e.bucket_start=b.start_at AND s.bucket_startstart_at THEN start_at END AS projection_from, + CASE WHEN end_at>start_at THEN COALESCE((SELECT max(bucket)+INTERVAL '1 hour' FROM clean + WHERE bucket=start_at+(position-1)*INTERVAL '1 hour'),start_at) END AS projection_through, + (SELECT count(*) FROM states WHERE (source_revision>built_revision OR pending) AND coverage_status<>'unrecoverable') AS dirty, + GREATEST(EXTRACT(EPOCH FROM(end_at-start_at))::bigint/3600,0)-(SELECT count(*) FROM states) AS missing + FROM bounds"#) + .bind(DateTime::::from_timestamp_millis(query.from_unix_ms as i64).expect("validated")) + .bind(DateTime::::from_timestamp_millis(query.to_unix_ms as i64).expect("validated")) + .fetch_one(&mut **tx).await.map_postgres_err()?; + Ok(UsageAnalyticsProjectionCoverage { + projection_from: row + .try_get::>, _>("projection_from") + .map_postgres_err()? + .map(|value| value.to_rfc3339()), + projection_through: row + .try_get::>, _>("projection_through") + .map_postgres_err()? + .map(|value| value.to_rfc3339()), + dirty_bucket_count: row.try_get::("dirty").map_postgres_err()? as u64, + missing_bucket_count: row.try_get::("missing").map_postgres_err()? as u64, + read_enabled, + }) +} + +pub(super) fn supports_projection(query: &UsageAnalyticsQuery) -> bool { + query.status.is_none() + && query.endpoint_kind.is_none() + && query.is_stream.is_none() + && query.has_format_conversion.is_none() + && query.slow_threshold_ms.is_none_or(|value| value == 5000) +} + +const ADDITIVE_COUNTS: &[&str] = &[ + "request_count", + "successful_request_count", + "failed_request_count", + "cancelled_request_count", + "in_flight_request_count", + "input_tokens", + "output_tokens", + "total_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + "usage_available_count", + "pricing_available_count", + "settled_count", + "allocation_available_count", + "trusted_attribution_count", + "classified_failure_count", + "latency_sample_count", + "slow_request_count", + "first_byte_sample_count", + "output_tps_sample_count", + "reported_usage_count", + "estimated_usage_count", + "mixed_usage_count", + "unknown_usage_count", + "cache_pricing_available_count", +]; + +const ADDITIVE_AMOUNTS: &[&str] = &[ + "rated_amount", + "billable_amount", + "quota_covered_amount", + "wallet_consumed_amount", + "wallet_debit_amount", + "wallet_recharge_debit_amount", + "wallet_gift_debit_amount", + "wallet_overdraft_amount", + "cache_read_cost_amount", + "cache_creation_cost_amount", + "cache_estimated_full_cost_amount", +]; + +/// Closed verified buckets and raw gaps are disjoint within the caller's read snapshot. +pub(super) async fn read_additive_summary( + tx: &mut sqlx::Transaction<'_, Postgres>, + query: &UsageAnalyticsQuery, +) -> Result { + read_projected_metrics( + tx, + query, + &analytics_additive_metrics_sql(5000), + ADDITIVE_COUNTS, + &["latency_sum_ms", "first_byte_sum_ms", "output_tps_sum"], + ADDITIVE_AMOUNTS, + false, + ) + .await +} + +pub(super) async fn read_dashboard_total_summary( + tx: &mut sqlx::Transaction<'_, Postgres>, + query: &UsageAnalyticsQuery, +) -> Result { + read_projected_metrics( + tx, + query, + dashboard_total_metrics_sql(), + &[ + "request_count", + "total_tokens", + "usage_available_count", + "pricing_available_count", + "settled_count", + "allocation_available_count", + ], + &[], + &["billable_amount"], + true, + ) + .await +} + +async fn read_projected_metrics( + tx: &mut sqlx::Transaction<'_, Postgres>, + query: &UsageAnalyticsQuery, + raw_metrics_sql: &str, + counts: &[&str], + float_sums: &[&str], + amounts: &[&str], + dashboard_total: bool, +) -> Result { + // Normal writers create UTC-aligned buckets. Ignore any manually supplied + // nonaligned state so the equality exclusion below still partitions facts. + // A writer can commit after a builder's snapshot without touching bucket state. + // Its event is committed atomically with the facts, so the same read snapshot + // must exclude that bucket until the event has been merged and rebuilt. + let mut builder = QueryBuilder::::new( + "WITH valid AS MATERIALIZED (SELECT bucket_start FROM stats_bucket_state WHERE projection_version='overview-v2' AND granularity='hour' AND source_revision=built_revision AND coverage_status='complete' AND NOT EXISTS (SELECT 1 FROM stats_overview_dirty_events e WHERE e.projection_version=stats_bucket_state.projection_version AND e.granularity=stats_bucket_state.granularity AND e.bucket_start=stats_bucket_state.bucket_start) AND bucket_start = date_trunc('hour', bucket_start AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AND bucket_start >= ", + ); + builder.push_bind(DateTime::::from_timestamp_millis(query.from_unix_ms as i64).expect("validated")) + .push(" AND bucket_start + INTERVAL '1 hour' <= ").push_bind(DateTime::::from_timestamp_millis(query.to_unix_ms as i64).expect("validated")) + .push(" AND bucket_start + INTERVAL '1 hour' <= NOW()), pieces AS (SELECT p.metrics FROM stats_overview_hourly p JOIN valid v USING(bucket_start) WHERE p.projection_version='overview-v2'"); + for (column, value) in [ + ("actor_user_id", &query.actor_user_id), + ("credential_owner_id", &query.credential_owner_id), + ("attribution_kind", &query.attribution_kind), + ("api_key_id", &query.api_key_id), + ("model", &query.model), + ("provider_id", &query.provider_id), + ("api_format", &query.api_format), + ("request_type", &query.request_type), + ] { + if let Some(value) = value { + builder + .push(" AND p.dimensions->>'") + .push(column) + .push("' = ") + .push_bind(value.clone()); + } + } + builder + .push(" UNION ALL SELECT to_jsonb(raw) FROM (SELECT ") + .push(raw_metrics_sql); + if dashboard_total { + super::dashboard::push_dashboard_total_filter(&mut builder, query); + } else { + push_analytics_filter(&mut builder, query); + } + builder.push(" AND NOT EXISTS (SELECT 1 FROM valid WHERE valid.bucket_start = date_trunc('hour', usage_analytics_facts_v1.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC')) raw) SELECT to_jsonb(m) AS metrics FROM (SELECT "); + for (index, name) in counts.iter().enumerate() { + if index > 0 { + builder.push(","); + } + builder + .push("COALESCE(sum((metrics->>'") + .push(*name) + .push("')::bigint),0)::bigint AS ") + .push(*name); + } + for name in float_sums { + builder + .push(",COALESCE(sum((metrics->>'") + .push(*name) + .push("')::double precision),0)::double precision AS ") + .push(*name); + } + for name in amounts { + builder + .push(",sum((metrics->>'") + .push(*name) + .push("')::numeric)::text AS ") + .push(*name); + } + builder.push(" FROM pieces) m"); + let row = builder + .build() + .fetch_one(&mut **tx) + .await + .map_postgres_err()?; + serde_json::from_value(row.try_get("metrics").map_postgres_err()?) + .map_err(|error| DataLayerError::UnexpectedValue(error.to_string())) +} diff --git a/crates/aether-data/adapters/postgres/src/wallet.rs b/crates/aether-data/adapters/postgres/src/wallet.rs index eb9311f8f..0c9538690 100644 --- a/crates/aether-data/adapters/postgres/src/wallet.rs +++ b/crates/aether-data/adapters/postgres/src/wallet.rs @@ -157,6 +157,7 @@ const COUNT_ADMIN_WALLETS_SQL: &str = r#" SELECT COUNT(*) AS total FROM wallets WHERE ($1::TEXT IS NULL OR status = $1) + AND ($3::TEXT IS NULL OR user_id = $3) AND ( $2::TEXT IS NULL OR ($2 = 'user' AND user_id IS NOT NULL) @@ -186,14 +187,15 @@ FROM wallets w LEFT JOIN users ON users.id = w.user_id LEFT JOIN api_keys ON api_keys.id = w.api_key_id WHERE ($1::TEXT IS NULL OR w.status = $1) + AND ($3::TEXT IS NULL OR w.user_id = $3) AND ( $2::TEXT IS NULL OR ($2 = 'user' AND w.user_id IS NOT NULL) OR ($2 = 'api_key' AND w.api_key_id IS NOT NULL) ) ORDER BY w.updated_at DESC -OFFSET $3 -LIMIT $4 +OFFSET $4 +LIMIT $5 "#; const COUNT_ADMIN_WALLET_LEDGER_SQL: &str = r#" @@ -1046,6 +1048,7 @@ impl WalletReadRepository for SqlxWalletRepository { sqlx::query(COUNT_ADMIN_WALLETS_SQL) .bind(query.status.as_deref()) .bind(query.owner_type.as_deref()) + .bind(query.user_id.as_deref()) .fetch_one(&self.pool) .await .map_postgres_err()?, @@ -1054,6 +1057,7 @@ impl WalletReadRepository for SqlxWalletRepository { sqlx::query(LIST_ADMIN_WALLETS_SQL) .bind(query.status.as_deref()) .bind(query.owner_type.as_deref()) + .bind(query.user_id.as_deref()) .bind(as_i64(query.offset, "wallet offset")?) .bind(as_i64(query.limit, "wallet limit")?) .fetch(&self.pool), diff --git a/crates/aether-data/contracts/src/repository/announcements.rs b/crates/aether-data/contracts/src/repository/announcements.rs index 9b99ea623..23320592b 100644 --- a/crates/aether-data/contracts/src/repository/announcements.rs +++ b/crates/aether-data/contracts/src/repository/announcements.rs @@ -97,6 +97,39 @@ pub struct StoredAnnouncementPage { pub total: u64, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct UserAnnouncementListQuery { + pub unread_only: bool, + pub offset: usize, + pub limit: usize, + pub now_unix_secs: u64, +} + +impl UserAnnouncementListQuery { + pub fn validate(&self) -> Result<(), crate::DataLayerError> { + if !(1..=100).contains(&self.limit) || i64::try_from(self.offset).is_err() { + return Err(crate::DataLayerError::InvalidInput( + "announcement limit must be between 1 and 100 and offset must fit in i64" + .to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct StoredUserAnnouncement { + pub announcement: StoredAnnouncement, + pub is_read: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +pub struct StoredUserAnnouncementPage { + pub items: Vec, + pub total: u64, + pub unread_count: u64, +} + fn parse_timestamp(value: i64, field: &str) -> Result { u64::try_from(value).map_err(|_| { crate::DataLayerError::UnexpectedValue(format!("{field} is negative: {value}")) @@ -115,6 +148,13 @@ pub trait AnnouncementReadRepository: Send + Sync { query: &AnnouncementListQuery, ) -> Result; + /// Counts and page share one snapshot; unread_count covers all currently active announcements. + async fn list_user_announcements( + &self, + user_id: &str, + query: &UserAnnouncementListQuery, + ) -> Result; + async fn count_unread_active_announcements( &self, user_id: &str, diff --git a/crates/aether-data/contracts/src/repository/billing/mod.rs b/crates/aether-data/contracts/src/repository/billing/mod.rs index 68fe35886..1a4fc9fef 100644 --- a/crates/aether-data/contracts/src/repository/billing/mod.rs +++ b/crates/aether-data/contracts/src/repository/billing/mod.rs @@ -1,3 +1,5 @@ +mod provider_expenses; +pub use provider_expenses::*; mod replacement; mod types; mod usage_policy; diff --git a/crates/aether-data/contracts/src/repository/billing/provider_expenses.rs b/crates/aether-data/contracts/src/repository/billing/provider_expenses.rs new file mode 100644 index 000000000..35a197064 --- /dev/null +++ b/crates/aether-data/contracts/src/repository/billing/provider_expenses.rs @@ -0,0 +1,325 @@ +//! An administrator-maintained purchasing ledger. Entries are not inferred from request prices. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +const SCALE: u128 = 100_000_000; + +pub fn provider_expense_amount_units(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() + || whole.len() > 12 + || !whole.bytes().all(|c| c.is_ascii_digit()) + || fraction.len() > 8 + || !fraction.bytes().all(|c| c.is_ascii_digit()) + { + return None; + } + let units = whole + .parse::() + .ok()? + .checked_mul(SCALE)? + .checked_add(if fraction.is_empty() { + 0 + } else { + fraction.parse::().ok()? * 10_u128.pow(8 - fraction.len() as u32) + })?; + (units > 0).then_some(units) +} + +pub fn format_provider_expense_amount(units: u128) -> String { + format!("{}.{:08}", units / SCALE, units % SCALE) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderExpenseInput { + pub client_request_id: String, + pub provider_id: String, + pub provider_name: String, + pub kind: String, + pub amount: String, + pub currency: String, + pub paid_at_unix_ms: u64, + pub period_start_unix_ms: Option, + pub period_end_unix_ms: Option, + pub note: Option, + pub external_reference: Option, + pub created_by: Option, +} + +impl ProviderExpenseInput { + /// Provider display names are snapshots, so renames do not break a retry. + pub fn same_request_as(&self, other: &Self) -> bool { + self.client_request_id == other.client_request_id + && self.provider_id == other.provider_id + && self.kind == other.kind + && provider_expense_amount_units(&self.amount) + == provider_expense_amount_units(&other.amount) + && self.currency == other.currency + && self.paid_at_unix_ms == other.paid_at_unix_ms + && self.period_start_unix_ms == other.period_start_unix_ms + && self.period_end_unix_ms == other.period_end_unix_ms + && self.note == other.note + && self.external_reference == other.external_reference + && self.created_by == other.created_by + } + pub fn validate(&self) -> Result<(), String> { + if uuid::Uuid::parse_str(&self.client_request_id).is_err() { + return Err("client_request_id must be a UUID".into()); + } + if self.provider_id.is_empty() + || self.provider_id.len() > 512 + || self.provider_name.is_empty() + || self.provider_name.len() > 512 + { + return Err("invalid provider identity".into()); + } + if !matches!(self.kind.as_str(), "recharge" | "subscription" | "other") { + return Err("kind must be recharge, subscription or other".into()); + } + if provider_expense_amount_units(&self.amount).is_none() { + return Err( + "amount must be positive with at most 12 integer and 8 decimal digits".into(), + ); + } + if self.currency.len() != 3 || !self.currency.bytes().all(|c| c.is_ascii_uppercase()) { + return Err("currency must be a 3-letter uppercase code".into()); + } + if self.paid_at_unix_ms > 253_402_300_799_000 + || self + .period_start_unix_ms + .is_some_and(|v| v > 253_402_300_799_000) + || self + .period_end_unix_ms + .is_some_and(|v| v > 253_402_300_799_000) + { + return Err("invalid timestamp".into()); + } + match (self.period_start_unix_ms, self.period_end_unix_ms) { + (None, None) => {} + (Some(start), Some(end)) if start < end => {} + _ => { + return Err( + "coverage period must contain both start and end, with start before end".into(), + ) + } + } + if self.note.as_ref().is_some_and(|v| { + v.len() > 2000 || v.chars().any(|c| c.is_control() && c != '\n' && c != '\t') + }) || self + .external_reference + .as_ref() + .is_some_and(|v| v.len() > 256 || v.chars().any(char::is_control)) + { + return Err("invalid note or external_reference".into()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderExpenseRecord { + pub id: String, + #[serde(flatten)] + pub entry: ProviderExpenseInput, + pub created_at_unix_ms: u64, + pub voided_at_unix_ms: Option, + pub voided_by: Option, +} + +#[derive(Debug, Clone)] +pub struct ProviderExpenseQuery { + pub from_unix_ms: u64, + pub to_unix_ms: u64, + pub limit: u32, + pub offset: u64, +} +impl ProviderExpenseQuery { + pub fn validate(&self) -> Result<(), crate::DataLayerError> { + if self.from_unix_ms >= self.to_unix_ms + || self.to_unix_ms > 253_402_300_799_000 + || self.to_unix_ms - self.from_unix_ms > 366 * 86_400_000 + || self.limit == 0 + || self.limit > 10_001 + || self.offset > i64::MAX as u64 + { + return Err(crate::DataLayerError::InvalidInput( + "invalid provider expense range or pagination".into(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProviderExpenseTotals { + pub currency: String, + pub amount: String, + pub recharge_amount: String, + pub subscription_amount: String, + pub other_amount: String, + pub entry_count: u64, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderExpenseProviderTotal { + pub provider_id: String, + pub provider_name: String, + pub currency: String, + pub amount: String, + pub entry_count: u64, +} +#[derive(Debug, Clone, Default)] +pub struct ProviderExpensePage { + pub items: Vec, + pub total: u64, + pub totals: Vec, + pub providers: Vec, +} + +pub fn provider_expense_memory_page( + entries: impl Iterator, + query: &ProviderExpenseQuery, +) -> Result { + query.validate()?; + let mut entries = entries + .filter(|r| { + r.voided_at_unix_ms.is_none() + && r.entry.paid_at_unix_ms >= query.from_unix_ms + && r.entry.paid_at_unix_ms < query.to_unix_ms + }) + .collect::>(); + entries.sort_by(|a, b| { + b.entry + .paid_at_unix_ms + .cmp(&a.entry.paid_at_unix_ms) + .then_with(|| b.id.cmp(&a.id)) + }); + let mut currencies = BTreeMap::::new(); + let mut providers = BTreeMap::<(String, String), (String, u128, u64)>::new(); + for record in &entries { + let row = &record.entry; + let units = provider_expense_amount_units(&row.amount).ok_or_else(|| { + crate::DataLayerError::UnexpectedValue("invalid recorded expense amount".into()) + })?; + let (amounts, count) = currencies.entry(row.currency.clone()).or_default(); + amounts[match row.kind.as_str() { + "recharge" => 0, + "subscription" => 1, + _ => 2, + }] += units; + *count += 1; + let (name, amount, count) = providers + .entry((row.provider_id.clone(), row.currency.clone())) + .or_insert_with(|| (row.provider_name.clone(), 0, 0)); + let _ = name; + *amount += units; + *count += 1; + } + Ok(ProviderExpensePage { + total: entries.len() as u64, + items: entries + .into_iter() + .skip(query.offset as usize) + .take(query.limit as usize) + .collect(), + totals: currencies + .into_iter() + .map(|(currency, (amounts, entry_count))| ProviderExpenseTotals { + currency, + amount: format_provider_expense_amount(amounts.iter().sum()), + recharge_amount: format_provider_expense_amount(amounts[0]), + subscription_amount: format_provider_expense_amount(amounts[1]), + other_amount: format_provider_expense_amount(amounts[2]), + entry_count, + }) + .collect(), + providers: providers + .into_iter() + .map( + |((provider_id, currency), (provider_name, amount, entry_count))| { + ProviderExpenseProviderTotal { + provider_id, + provider_name, + currency, + amount: format_provider_expense_amount(amount), + entry_count, + } + }, + ) + .collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn provider_expense_amounts_are_exact_and_reject_ambiguous_inputs() { + assert_eq!( + format_provider_expense_amount(provider_expense_amount_units("0.12345678").unwrap()), + "0.12345678" + ); + for bad in [ + "0", + "-1", + "+1", + "1e2", + "NaN", + "1.000000001", + "1000000000000", + " 1", + ".1", + ] { + assert!(provider_expense_amount_units(bad).is_none(), "{bad}"); + } + } + #[test] + fn provider_expense_report_has_currency_separation_void_exclusion_and_full_page_totals() { + let entry = + |id: &str, currency: &str, amount: &str, kind: &str, paid: u64, voided: bool| { + ProviderExpenseRecord { + id: id.into(), + entry: ProviderExpenseInput { + client_request_id: uuid::Uuid::new_v4().to_string(), + provider_id: "p".into(), + provider_name: "Supplier".into(), + kind: kind.into(), + amount: amount.into(), + currency: currency.into(), + paid_at_unix_ms: paid, + period_start_unix_ms: None, + period_end_unix_ms: None, + note: None, + external_reference: None, + created_by: None, + }, + created_at_unix_ms: paid, + voided_at_unix_ms: voided.then_some(paid + 1), + voided_by: None, + } + }; + let rows = vec![ + entry("1", "USD", "0.1", "recharge", 10, false), + entry("2", "USD", "0.2", "subscription", 20, false), + entry("3", "CNY", "5", "other", 20, false), + entry("4", "USD", "99", "recharge", 20, true), + entry("5", "USD", "99", "recharge", 30, false), + ]; + let page = provider_expense_memory_page( + rows.into_iter(), + &ProviderExpenseQuery { + from_unix_ms: 10, + to_unix_ms: 30, + limit: 1, + offset: 1, + }, + ) + .unwrap(); + assert_eq!(page.total, 3); + assert_eq!(page.items.len(), 1); + assert_eq!(page.totals[0].currency, "CNY"); + assert_eq!(page.totals[0].amount, "5.00000000"); + assert_eq!(page.totals[1].amount, "0.30000000"); + assert_eq!(page.totals[1].recharge_amount, "0.10000000"); + assert_eq!(page.totals[1].subscription_amount, "0.20000000"); + } +} diff --git a/crates/aether-data/contracts/src/repository/billing/types.rs b/crates/aether-data/contracts/src/repository/billing/types.rs index 82b891e1b..1b0e61f0c 100644 --- a/crates/aether-data/contracts/src/repository/billing/types.rs +++ b/crates/aether-data/contracts/src/repository/billing/types.rs @@ -576,6 +576,31 @@ pub trait BillingReadRepository: Send + Sync { Ok(AdminBillingMutationOutcome::Unavailable) } + async fn list_provider_expenses( + &self, + query: &super::ProviderExpenseQuery, + ) -> Result, crate::DataLayerError> { + let _ = query; + Ok(None) + } + async fn create_provider_expense( + &self, + input: &super::ProviderExpenseInput, + ) -> Result, crate::DataLayerError> + { + let _ = input; + Ok(AdminBillingMutationOutcome::Unavailable) + } + async fn void_provider_expense( + &self, + id: &str, + operator: Option<&str>, + ) -> Result, crate::DataLayerError> + { + let _ = (id, operator); + Ok(AdminBillingMutationOutcome::Unavailable) + } + async fn list_billing_plans( &self, include_disabled: bool, @@ -634,6 +659,18 @@ pub trait BillingReadRepository: Send + Sync { Ok(None) } + async fn list_user_plan_entitlements_with_history( + &self, + user_id: &str, + include_inactive: bool, + ) -> Result>, crate::DataLayerError> { + if include_inactive { + Ok(None) + } else { + self.list_user_plan_entitlements(user_id).await + } + } + async fn revoke_user_plan_entitlement( &self, user_id: &str, diff --git a/crates/aether-data/contracts/src/repository/usage/analytics.rs b/crates/aether-data/contracts/src/repository/usage/analytics.rs new file mode 100644 index 000000000..d00c0203e --- /dev/null +++ b/crates/aether-data/contracts/src/repository/usage/analytics.rs @@ -0,0 +1,454 @@ +use serde::{Deserialize, Serialize}; + +pub const USAGE_ANALYTICS_VERSION: &str = "overview-v2"; +pub const USAGE_ANALYTICS_MAX_RANGE_MS: u64 = 366 * 24 * 60 * 60 * 1000; +pub const USAGE_DASHBOARD_CHART_ROW_LIMIT: usize = 10_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageDashboardAnalyticsQuery { + pub timezone: String, +} + +impl UsageDashboardAnalyticsQuery { + pub fn validate(&self) -> Result<(), crate::DataLayerError> { + self.timezone + .parse::() + .map(|_| ()) + .map_err(|_| crate::DataLayerError::InvalidInput("invalid analytics timezone".into())) + } + + pub fn today_start( + &self, + now: chrono::DateTime, + ) -> Result, crate::DataLayerError> { + self.validate()?; + let timezone = self.timezone.parse::().expect("validated"); + local_day_start(timezone, now.with_timezone(&timezone).date_naive()).ok_or_else(|| { + crate::DataLayerError::InvalidInput("reporting day boundary is unavailable".into()) + }) + } +} + +fn local_day_start( + timezone: chrono_tz::Tz, + day: chrono::NaiveDate, +) -> Option> { + use chrono::TimeZone; + let midnight = day.and_hms_opt(0, 0, 0)?; + // IANA transitions can skip midnight or an entire local calendar day. + (0..1440) + .find_map(|minutes| { + timezone + .from_local_datetime(&(midnight + chrono::Duration::minutes(minutes))) + .earliest() + }) + .map(|value| value.with_timezone(&chrono::Utc)) +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct StoredUsageDashboardAnalytics { + pub today: StoredUsageAnalytics, + // Lifetime card totals and coverage only; historical diagnostics are not computed. + pub total: StoredUsageAnalytics, + pub today_from: String, + pub total_from: Option, + pub to: String, + // False means known lost history; None means installation-wide retention is unproven. + pub history_complete: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageAnalyticsView { + #[default] + Summary, + Timeseries, + Breakdown, + Users, + Consumption, + Performance, + DashboardCharts, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageAnalyticsGroupBy { + #[default] + Model, + Provider, + ApiKey, + Attribution, + ApiFormat, + RequestType, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageAnalyticsGranularity { + Hour, + #[default] + Day, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageAnalyticsSort { + #[default] + Requests, + BillableAmount, + LastUsed, + Username, + Tokens, + ActiveDays, + StartedAt, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsQuery { + pub from_unix_ms: u64, + pub to_unix_ms: u64, + pub timezone: String, + pub view: UsageAnalyticsView, + pub group_by: UsageAnalyticsGroupBy, + pub granularity: UsageAnalyticsGranularity, + pub actor_user_id: Option, + pub credential_owner_id: Option, + pub attribution_kind: Option, + pub api_key_id: Option, + pub model: Option, + pub provider_id: Option, + pub api_format: Option, + pub endpoint_kind: Option, + pub request_type: Option, + pub status: Option, + pub is_stream: Option, + pub has_format_conversion: Option, + pub slow_threshold_ms: Option, + pub search: Option, + pub user_is_active: Option, + pub has_usage: Option, + pub sort: UsageAnalyticsSort, + pub descending: bool, + pub limit: u32, + pub offset: u64, + #[serde(default)] + pub payment_limit: Option, + #[serde(default)] + pub payment_offset: Option, +} + +impl UsageAnalyticsQuery { + pub fn validate(&self) -> Result<(), crate::DataLayerError> { + if self.from_unix_ms >= self.to_unix_ms + || self.to_unix_ms - self.from_unix_ms > USAGE_ANALYTICS_MAX_RANGE_MS + || self.to_unix_ms > 253_402_300_799_000 + { + return Err(crate::DataLayerError::InvalidInput( + "analytics range must be nonempty and at most 366 days".into(), + )); + } + if self.view == UsageAnalyticsView::DashboardCharts + && self.granularity == UsageAnalyticsGranularity::Hour + && self.to_unix_ms - self.from_unix_ms > 31 * 24 * 60 * 60 * 1000 + { + return Err(crate::DataLayerError::InvalidInput( + "hourly dashboard charts are limited to 31 days".into(), + )); + } + if self.timezone.parse::().is_err() { + return Err(crate::DataLayerError::InvalidInput( + "invalid analytics timezone".into(), + )); + } + if self.limit == 0 || self.limit > 10_001 || self.offset > i64::MAX as u64 { + return Err(crate::DataLayerError::InvalidInput( + "invalid analytics pagination".into(), + )); + } + if self + .payment_limit + .is_some_and(|value| value == 0 || value > 100) + || self + .payment_offset + .is_some_and(|value| value > i64::MAX as u64) + || (self.view != UsageAnalyticsView::Users + && (self.payment_limit.is_some() || self.payment_offset.is_some())) + { + return Err(crate::DataLayerError::InvalidInput( + "invalid user payment pagination".into(), + )); + } + if self + .attribution_kind + .as_deref() + .is_some_and(|kind| !matches!(kind, "employee" | "standalone" | "unknown")) + { + return Err(crate::DataLayerError::InvalidInput( + "invalid attribution kind".into(), + )); + } + Ok(()) + } +} + +/// Raw domain metrics. Amounts are per-request normalized decimal sums, never floats. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UsageAnalyticsMetrics { + pub request_count: u64, + pub successful_request_count: u64, + pub failed_request_count: u64, + pub cancelled_request_count: u64, + pub in_flight_request_count: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cache_read_input_tokens: u64, + pub cache_creation_input_tokens: u64, + pub cache_pricing_available_count: u64, + pub cache_read_cost_amount: Option, + pub cache_creation_cost_amount: Option, + pub cache_estimated_full_cost_amount: Option, + pub usage_active_users: u64, + pub enabled_users: u64, + pub usage_available_count: u64, + pub reported_usage_count: u64, + pub estimated_usage_count: u64, + pub mixed_usage_count: u64, + pub unknown_usage_count: u64, + pub pricing_available_count: u64, + pub settled_count: u64, + pub allocation_available_count: u64, + pub trusted_attribution_count: u64, + pub classified_failure_count: u64, + pub latency_sample_count: u64, + pub slow_request_count: u64, + pub latency_sum_ms: f64, + pub latency_p50_ms: Option, + pub latency_p95_ms: Option, + pub latency_p90_ms: Option, + pub latency_p99_ms: Option, + pub first_byte_sample_count: u64, + pub first_byte_sum_ms: f64, + pub first_byte_p90_ms: Option, + pub first_byte_p99_ms: Option, + pub output_tps_sample_count: u64, + pub output_tps_sum: f64, + pub rated_amount: Option, + pub billable_amount: Option, + pub quota_covered_amount: Option, + pub wallet_consumed_amount: Option, + pub wallet_debit_amount: Option, + pub wallet_recharge_debit_amount: Option, + pub wallet_gift_debit_amount: Option, + pub wallet_overdraft_amount: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageAnalyticsRow { + pub id: Option, + pub label: Option, + pub bucket_start: Option, + pub metrics: UsageAnalyticsMetrics, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageAnalyticsUser { + pub user_id: String, + pub username: String, + pub email: Option, + pub is_active: bool, + pub last_used_at: Option, + pub active_days: u64, + pub metrics: UsageAnalyticsMetrics, + #[serde(default)] + pub finance: Option, +} + +/// Current balances and gross credited orders in the requested time range. +/// Amounts are USD decimals. Gift-code/admin-grant orders and plan purchases +/// remain separate from wallet recharges; refunds are not assigned to the +/// original credit period. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsUserFinance { + pub wallet_balance: Option, + pub recharge_balance: Option, + pub gift_balance: Option, + pub recharge_amount: Option, + pub recharge_count: u64, + pub plan_purchase_amount: Option, + pub plan_purchase_count: u64, + pub gift_credit_amount: Option, + pub gift_credit_count: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsUserPayment { + pub id: String, + pub order_no: String, + pub kind: String, + pub amount: String, + pub payment_method: String, + pub credited_at: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsUserPayments { + pub items: Vec, + pub total: u64, + pub limit: u32, + pub offset: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct UsageAnalyticsUserSummary { + pub user_count: u64, + pub active_user_count: u64, + pub metrics: UsageAnalyticsMetrics, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageAnalyticsConsumption { + pub id: String, + pub request_id: String, + pub started_at: String, + pub user_id: Option, + pub credential_owner_id: Option, + pub model: String, + pub provider: Option, + pub provider_id: Option, + pub api_key_id: Option, + pub status: String, + pub settlement_status: String, + pub attribution_kind: String, + pub attribution_source: String, + pub rated_amount: Option, + pub billable_amount: Option, + pub quota_covered_amount: Option, + pub wallet_consumed_amount: Option, + pub wallet_debit_amount: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsAllocation { + pub request_id: String, + pub quota_covered_amount: Option, + pub wallet_consumed_amount: Option, + pub wallet_debit_amount: Option, + pub wallet_recharge_debit_amount: Option, + pub wallet_gift_debit_amount: Option, + pub wallet_overdraft_amount: Option, + pub cache_read_cost_amount: Option, + pub cache_creation_cost_amount: Option, + pub cache_estimated_full_cost_amount: Option, + pub complete: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct StoredUsageAnalytics { + pub summary: UsageAnalyticsMetrics, + pub rows: Vec, + pub users: Vec, + #[serde(default)] + pub user_summary: Option, + #[serde(default)] + pub user_finance_summary: Option, + #[serde(default)] + pub user_payments: Option, + pub consumption: Vec, + pub provider_rows: Vec, + pub provider_timeline_rows: Vec, + pub model_rows: Vec, + pub errors: Vec, + pub total: u64, + pub read_revision: String, + pub generated_at: String, + pub data_through: Option, + pub unrecoverable_bucket_count: u64, + pub coverage: UsageAnalyticsProjectionCoverage, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsProjectionCoverage { + pub projection_from: Option, + pub projection_through: Option, + pub dirty_bucket_count: u64, + pub missing_bucket_count: u64, + pub read_enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAnalyticsErrorCount { + pub reason: String, + pub count: u64, +} + +pub fn fill_usage_analytics_timeseries( + query: &UsageAnalyticsQuery, + rows: &mut Vec, +) { + use chrono::{Timelike, Utc}; + let timezone = query + .timezone + .parse::() + .expect("validated timezone"); + let from = chrono::DateTime::::from_timestamp_millis(query.from_unix_ms as i64) + .expect("validated timestamp"); + let mut bucket = match query.granularity { + UsageAnalyticsGranularity::Hour => from + .with_minute(0) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + .expect("hour"), + UsageAnalyticsGranularity::Day => { + let day = from.with_timezone(&timezone).date_naive(); + let Some(value) = local_day_start(timezone, day) else { + return; + }; + value.with_timezone(&Utc) + } + }; + let mut existing = std::mem::take(rows) + .into_iter() + .filter_map(|row| { + let start = row + .bucket_start + .as_ref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())? + .timestamp_millis(); + Some((start, row)) + }) + .collect::>(); + while bucket.timestamp_millis() < query.to_unix_ms as i64 { + let start = bucket.to_rfc3339(); + rows.push( + existing + .remove(&bucket.timestamp_millis()) + .unwrap_or_else(|| UsageAnalyticsRow { + id: Some(start.clone()), + label: Some(start.clone()), + bucket_start: Some(start), + metrics: UsageAnalyticsMetrics { + rated_amount: Some("0.00000000".into()), + billable_amount: Some("0.00000000".into()), + ..Default::default() + }, + }), + ); + bucket = match query.granularity { + UsageAnalyticsGranularity::Hour => bucket + chrono::Duration::hours(1), + UsageAnalyticsGranularity::Day => { + let mut day = bucket.with_timezone(&timezone).date_naive(); + loop { + let Some(next) = day.succ_opt() else { + return; + }; + day = next; + if let Some(value) = local_day_start(timezone, day) { + break value; + } + } + } + }; + } +} diff --git a/crates/aether-data/contracts/src/repository/usage/analytics_tests.rs b/crates/aether-data/contracts/src/repository/usage/analytics_tests.rs new file mode 100644 index 000000000..bd960dcb1 --- /dev/null +++ b/crates/aether-data/contracts/src/repository/usage/analytics_tests.rs @@ -0,0 +1,102 @@ +use super::*; +use chrono::DateTime; + +fn query( + from: &str, + to: &str, + timezone: &str, + granularity: UsageAnalyticsGranularity, +) -> UsageAnalyticsQuery { + UsageAnalyticsQuery { + from_unix_ms: DateTime::parse_from_rfc3339(from) + .unwrap() + .timestamp_millis() as u64, + to_unix_ms: DateTime::parse_from_rfc3339(to).unwrap().timestamp_millis() as u64, + timezone: timezone.into(), + granularity, + limit: 25, + ..Default::default() + } +} + +#[test] +fn local_days_follow_dst_and_empty_buckets_remain_visible() { + let query = query( + "2026-03-07T05:00:00Z", + "2026-03-10T04:00:00Z", + "America/New_York", + UsageAnalyticsGranularity::Day, + ); + let mut rows = Vec::new(); + fill_usage_analytics_timeseries(&query, &mut rows); + assert_eq!(rows.len(), 3); + assert_eq!( + rows[2].bucket_start.as_deref(), + Some("2026-03-09T04:00:00+00:00") + ); + assert_eq!( + rows[0].metrics.billable_amount.as_deref(), + Some("0.00000000") + ); +} + +#[test] +fn repeated_dst_hours_are_distinct_and_range_is_half_open() { + let query = query( + "2026-11-01T04:00:00Z", + "2026-11-01T08:00:00Z", + "America/New_York", + UsageAnalyticsGranularity::Hour, + ); + let mut rows = Vec::new(); + fill_usage_analytics_timeseries(&query, &mut rows); + assert_eq!(rows.len(), 4); + assert_ne!(rows[1].bucket_start, rows[2].bucket_start); +} + +#[test] +fn dashboard_today_uses_local_day_including_skipped_midnight() { + let query = UsageDashboardAnalyticsQuery { + timezone: "America/Sao_Paulo".into(), + }; + let now = DateTime::parse_from_rfc3339("2018-11-04T12:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + assert_eq!( + query.today_start(now).unwrap().to_rfc3339(), + "2018-11-04T03:00:00+00:00" + ); + let query = UsageDashboardAnalyticsQuery { + timezone: "Asia/Shanghai".into(), + }; + assert_eq!( + query.today_start(now).unwrap().to_rfc3339(), + "2018-11-03T16:00:00+00:00" + ); + assert!(UsageDashboardAnalyticsQuery { + timezone: "invalid".into() + } + .validate() + .is_err()); +} + +#[test] +fn daily_series_continues_after_skipped_midnight() { + let query = query( + "2026-09-05T04:00:00Z", + "2026-09-08T03:00:00Z", + "America/Santiago", + UsageAnalyticsGranularity::Day, + ); + let mut rows = Vec::new(); + fill_usage_analytics_timeseries(&query, &mut rows); + assert_eq!(rows.len(), 3); + assert_eq!( + rows[1].bucket_start.as_deref(), + Some("2026-09-06T04:00:00+00:00") + ); + assert_eq!( + rows[2].bucket_start.as_deref(), + Some("2026-09-07T03:00:00+00:00") + ); +} diff --git a/crates/aether-data/contracts/src/repository/usage/attribution.rs b/crates/aether-data/contracts/src/repository/usage/attribution.rs new file mode 100644 index 000000000..9b42de6d5 --- /dev/null +++ b/crates/aether-data/contracts/src/repository/usage/attribution.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageAttributionSnapshot { + pub request_id: String, + pub actor_user_id: Option, + pub credential_owner_id: Option, + pub attribution_kind: String, + pub attribution_source: String, + pub record_kind: String, + pub parent_request_id: Option, + pub schema_version: u32, + pub attribution_revision: u64, +} + +impl UsageAttributionSnapshot { + pub fn validate(&self) -> Result<(), crate::DataLayerError> { + if self.request_id.is_empty() + || self.attribution_revision == 0 + || self.schema_version != 1 + || !matches!( + self.attribution_kind.as_str(), + "employee" | "standalone" | "unknown" + ) + || !matches!( + self.attribution_source.as_str(), + "user_account" | "standalone_key" | "unknown" + ) + || (self.attribution_kind == "employee") != self.actor_user_id.is_some() + || (self.attribution_kind == "employee" + && (self.actor_user_id != self.credential_owner_id + || self.attribution_source != "user_account")) + || (self.attribution_kind == "standalone" + && (self.credential_owner_id.is_none() + || self.attribution_source != "standalone_key")) + || (self.attribution_kind == "unknown" && self.attribution_source != "unknown") + { + return Err(crate::DataLayerError::InvalidInput( + "invalid usage attribution snapshot".into(), + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::UsageAttributionSnapshot; + + #[test] + fn account_attribution_requires_matching_owner_and_key_classification() { + let mut snapshot = UsageAttributionSnapshot { + request_id: "request".into(), + actor_user_id: Some("member".into()), + credential_owner_id: Some("member".into()), + attribution_kind: "employee".into(), + attribution_source: "user_account".into(), + record_kind: "request".into(), + parent_request_id: None, + schema_version: 1, + attribution_revision: 2, + }; + assert!(snapshot.validate().is_ok()); + snapshot.actor_user_id = Some("another-member".into()); + assert!(snapshot.validate().is_err()); + snapshot.actor_user_id = None; + snapshot.attribution_kind = "standalone".into(); + snapshot.attribution_source = "standalone_key".into(); + assert!(snapshot.validate().is_ok()); + snapshot.credential_owner_id = None; + assert!(snapshot.validate().is_err()); + snapshot.attribution_kind = "unknown".into(); + snapshot.attribution_source = "unknown".into(); + assert!(snapshot.validate().is_ok()); + } +} diff --git a/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs b/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs new file mode 100644 index 000000000..6ec7877cf --- /dev/null +++ b/crates/aether-data/contracts/src/repository/usage/dashboard_summary.rs @@ -0,0 +1,126 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +/// Additive dashboard facts collected after this installation enabled aggregation. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DashboardSummaryMetrics { + pub request_count: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub usage_available_count: u64, + pub pricing_available_count: u64, + pub billable_amount: Option, + pub active_users: u64, + pub cache_read_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_input_tokens: u64, + pub first_byte_sum_ms: f64, + pub first_byte_sample_count: u64, + pub response_sum_ms: f64, + pub response_sample_count: u64, + pub stream_requests: u64, + pub standard_requests: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DashboardUserCounts { + pub total: u64, + pub created_today: u64, + pub deleted_today: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DashboardActivityDay { + pub date: String, + pub requests: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct StoredDashboardSummary { + pub stats_since: String, + pub generated_at: String, + pub timezone: String, + pub today_from: String, + pub window_seconds: f64, + pub today: DashboardSummaryMetrics, + pub total: DashboardSummaryMetrics, + pub users: DashboardUserCounts, + pub active_days: u64, + #[serde(default)] + pub consecutive_active_days: u64, + pub activity_days: Vec, +} + +/// Current activity streak from distinct local dates ordered oldest to newest. +/// An unfinished today may be inactive, so a streak ending yesterday still counts. +pub fn dashboard_consecutive_active_days( + days: impl DoubleEndedIterator, + today: NaiveDate, +) -> u64 { + let mut days = days.rev().filter(|date| *date <= today); + let Some(mut latest) = days.next() else { + return 0; + }; + if latest != today && Some(latest) != today.pred_opt() { + return 0; + } + let mut consecutive = 1; + for date in days { + if Some(date) != latest.pred_opt() { + break; + } + consecutive += 1; + latest = date; + } + consecutive +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(value: &str) -> NaiveDate { + NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap() + } + + #[test] + fn dashboard_activity_streak_handles_empty_stale_and_interrupted_days() { + let today = date("2026-09-19"); + assert_eq!(dashboard_consecutive_active_days([].into_iter(), today), 0); + assert_eq!( + dashboard_consecutive_active_days([date("2026-09-17")].into_iter(), today), + 0 + ); + assert_eq!( + dashboard_consecutive_active_days( + ["2026-09-15", "2026-09-17", "2026-09-18", "2026-09-19"] + .map(date) + .into_iter(), + today, + ), + 3 + ); + } + + #[test] + fn dashboard_activity_streak_can_end_yesterday_across_month_and_year() { + assert_eq!( + dashboard_consecutive_active_days( + ["2025-12-30", "2025-12-31", "2026-01-01"] + .map(date) + .into_iter(), + date("2026-01-02"), + ), + 3 + ); + } + + #[test] + fn dashboard_activity_streak_uses_full_history_and_ignores_future_dates() { + let today = date("2026-09-19"); + let days = (-399..=1).map(|offset| today + chrono::Duration::days(offset)); + assert_eq!(dashboard_consecutive_active_days(days, today), 400); + } +} diff --git a/crates/aether-data/contracts/src/repository/usage/health.rs b/crates/aether-data/contracts/src/repository/usage/health.rs new file mode 100644 index 000000000..67a20183f --- /dev/null +++ b/crates/aether-data/contracts/src/repository/usage/health.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthObservationObjectKind { + ApiFormat, + Model, + Provider, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HealthObservationQuery { + pub from_unix_ms: u64, + pub to_unix_ms: u64, + pub object_kind: HealthObservationObjectKind, + /// None means all authorized administrative objects; Some(empty) means no objects. + pub object_values: Option>, + pub segments: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct HealthObservationMetrics { + pub request_count: u64, + pub succeeded_count: u64, + pub failed_count: u64, + pub in_progress_count: u64, + pub cancelled_count: u64, + pub service_succeeded_count: u64, + pub service_failed_count: u64, + pub excluded_count: u64, + pub unknown_failure_count: u64, + pub attempt_succeeded_count: u64, + pub attempt_failed_count: u64, + pub attempt_in_progress_count: u64, + pub attempt_cancelled_count: u64, + pub latency_sum_ms: f64, + pub latency_sample_count: u64, + pub last_request_at_unix_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HealthObservationBucket { + pub from_unix_ms: u64, + pub to_unix_ms: u64, + pub metrics: HealthObservationMetrics, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HealthObservationObject { + pub object_value: String, + pub metrics: HealthObservationMetrics, + pub timeline: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct HealthObservationSummary { + pub overall: HealthObservationMetrics, + pub objects: Vec, + pub timeline: Vec, + pub data_through_unix_ms: Option, +} diff --git a/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs b/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs index 70b2fdca2..2e98472ec 100644 --- a/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs +++ b/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs @@ -44,6 +44,40 @@ pub fn sanitize_usage_request_metadata_ref(value: Option<&Value>) -> Option) -> Option { let mut target = Map::new(); + if let Some(source) = source + .get("analytics_measurement") + .and_then(|value| value.get("source")) + .and_then(Value::as_str) + .filter(|source| matches!(*source, "reported" | "estimated" | "mixed" | "unknown")) + { + target.insert( + "analytics_measurement".into(), + serde_json::json!({"source":source}), + ); + } + for (key, fields) in [ + ( + "analytics_attribution", + &["record_kind", "parent_request_id"][..], + ), + ("analytics_failure", &["origin", "stage", "reason"][..]), + ] { + if let Some(object) = source.get(key).and_then(Value::as_object) { + let mut projected = Map::new(); + for field in fields { + insert_token(object, &mut projected, field, 128); + } + if key == "analytics_attribution" { + if let Some(value) = object.get("is_standalone").and_then(Value::as_bool) { + projected.insert("is_standalone".into(), Value::Bool(value)); + } + } + insert_bounded_u64(object, &mut projected, "schema_version", 1); + if !projected.is_empty() { + target.insert(key.into(), Value::Object(projected)); + } + } + } insert_token(source, &mut target, "trace_id", 128); insert_ip_address(source, &mut target, "client_ip"); @@ -1224,6 +1258,27 @@ mod tests { use super::{sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref}; + #[test] + fn account_attribution_preserves_key_flag_without_custom_identity_or_purpose() { + let metadata = sanitize_usage_request_metadata(Some(json!({ + "analytics_attribution": { + "is_standalone": false, + "record_kind": "request", + "actor_user_id": "another-member", + "credential_kind": "personal", + "source": "trusted_identity" + } + }))) + .unwrap(); + assert_eq!( + metadata["analytics_attribution"], + json!({ + "is_standalone": false, + "record_kind": "request" + }) + ); + } + #[test] fn persistence_projection_drops_credentials_and_free_diagnostics() { let metadata = sanitize_usage_request_metadata(Some(json!({ diff --git a/crates/aether-data/contracts/src/repository/usage/mod.rs b/crates/aether-data/contracts/src/repository/usage/mod.rs index ed2377a3a..2cd9f0bfa 100644 --- a/crates/aether-data/contracts/src/repository/usage/mod.rs +++ b/crates/aether-data/contracts/src/repository/usage/mod.rs @@ -1,15 +1,25 @@ +mod analytics; +#[cfg(test)] +mod analytics_tests; +mod attribution; mod capture_memory; mod compression; +mod dashboard_summary; +mod health; mod metadata_policy; mod policy; mod types; +pub use analytics::*; +pub use attribution::*; #[doc(hidden)] pub use capture_memory::{ mark_usage_capture_memory_omitted, usage_json_heap_estimate, UsageCaptureMemoryBudget, UsageCaptureRetention, }; pub use compression::{read_decompressed_usage_json, MAX_DECOMPRESSED_USAGE_JSON_BYTES}; +pub use dashboard_summary::*; +pub use health::*; pub use metadata_policy::*; pub use policy::*; pub use types::{ diff --git a/crates/aether-data/contracts/src/repository/usage/types.rs b/crates/aether-data/contracts/src/repository/usage/types.rs index a2b88639a..ff5d07643 100644 --- a/crates/aether-data/contracts/src/repository/usage/types.rs +++ b/crates/aether-data/contracts/src/repository/usage/types.rs @@ -995,6 +995,24 @@ pub struct StoredProviderApiKeyWindowUsageSummary { #[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub struct UsageAuditListQuery { + #[serde(default)] + pub slow_threshold_ms: Option, + #[serde(default)] + pub endpoint_kind: Option, + #[serde(default)] + pub request_type: Option, + #[serde(default)] + pub has_format_conversion: Option, + #[serde(default)] + pub provider_id: Option, + #[serde(default)] + pub api_key_id: Option, + #[serde(default)] + pub request_id: Option, + #[serde(default)] + pub attribution_kind: Option, + #[serde(default)] + pub actor_user_id: Option, pub created_from_unix_secs: Option, pub created_until_unix_secs: Option, pub user_id: Option, @@ -1015,6 +1033,24 @@ pub struct UsageAuditListQuery { #[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub struct UsageAuditKeywordSearchQuery { + #[serde(default)] + pub slow_threshold_ms: Option, + #[serde(default)] + pub endpoint_kind: Option, + #[serde(default)] + pub request_type: Option, + #[serde(default)] + pub has_format_conversion: Option, + #[serde(default)] + pub provider_id: Option, + #[serde(default)] + pub api_key_id: Option, + #[serde(default)] + pub request_id: Option, + #[serde(default)] + pub attribution_kind: Option, + #[serde(default)] + pub actor_user_id: Option, pub created_from_unix_secs: Option, pub created_until_unix_secs: Option, pub user_id: Option, @@ -1740,6 +1776,42 @@ pub enum StoredUsageBodyPayload { #[async_trait] pub trait UsageReadRepository: Send + Sync { + async fn query_dashboard_summary( + &self, + _query: &super::UsageDashboardAnalyticsQuery, + ) -> Result { + Err(crate::DataLayerError::UnexpectedValue( + "dashboard summary repository unavailable".into(), + )) + } + + async fn query_dashboard_analytics( + &self, + _query: &super::UsageDashboardAnalyticsQuery, + ) -> Result { + Err(crate::DataLayerError::UnexpectedValue( + "dashboard analytics repository unavailable".into(), + )) + } + + async fn summarize_health_observations( + &self, + _query: &super::HealthObservationQuery, + ) -> Result { + Err(crate::DataLayerError::UnexpectedValue( + "health observations repository unavailable".into(), + )) + } + + async fn query_usage_analytics( + &self, + _query: &super::UsageAnalyticsQuery, + ) -> Result { + Err(crate::DataLayerError::UnexpectedValue( + "usage analytics repository unavailable".into(), + )) + } + async fn find_by_id( &self, id: &str, diff --git a/crates/aether-data/contracts/src/repository/wallet/snapshot.rs b/crates/aether-data/contracts/src/repository/wallet/snapshot.rs index 796a753be..06320f32a 100644 --- a/crates/aether-data/contracts/src/repository/wallet/snapshot.rs +++ b/crates/aether-data/contracts/src/repository/wallet/snapshot.rs @@ -105,6 +105,12 @@ impl WalletReadSnapshot { .as_deref() .is_none_or(|expected| wallet.status == expected) }) + .filter(|wallet| { + query + .user_id + .as_deref() + .is_none_or(|expected| wallet.user_id.as_deref() == Some(expected)) + }) .filter(|wallet| match query.owner_type.as_deref() { Some("user") => wallet.user_id.is_some(), Some("api_key") => wallet.api_key_id.is_some(), diff --git a/crates/aether-data/contracts/src/repository/wallet/types.rs b/crates/aether-data/contracts/src/repository/wallet/types.rs index 25a7e8ea5..253b9262b 100644 --- a/crates/aether-data/contracts/src/repository/wallet/types.rs +++ b/crates/aether-data/contracts/src/repository/wallet/types.rs @@ -114,6 +114,7 @@ impl StoredWalletSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub struct AdminWalletListQuery { + pub user_id: Option, pub status: Option, pub owner_type: Option, pub limit: usize, diff --git a/crates/aether-data/runtime/schema/README.md b/crates/aether-data/runtime/schema/README.md index f0f47c4f3..f178e52c7 100644 --- a/crates/aether-data/runtime/schema/README.md +++ b/crates/aether-data/runtime/schema/README.md @@ -109,3 +109,72 @@ remains byte-for-byte stable when composed: The Rust migration tests compose these manifests too, so fragment drift is caught during `cargo test -p aether-data split_baseline_sources_match_executable_migrations`. + +## Statistics Migrations + +The statistics release retains its applied migration history and includes +incremental upgrades for databases that ran the earlier overview and dashboard +definitions. Concurrent index operations remain separate because PostgreSQL +cannot run them inside a transaction. + +| Version | Change | +|---|---| +| `20260911000000` | Overview facts, attribution, aggregate tables, and transaction-owned dirty-event queue. Attribution indexes are created while the new table is empty. | +| `20260917000000` | Original account-attribution migration, retained byte-for-byte for databases that already applied it. | +| `20260917000100` | Upgrade the original attribution trigger to the dirty-event queue before later concurrent index builds. | +| `20260918000000` | Create the replacement settlement covering index concurrently. | +| `20260918000100` | Drop the previous settlement covering index concurrently, after its replacement succeeds. | +| `20260919000000` | Dashboard aggregates, activation boundary, and retention support. | +| `20260920000000` | Create the credited-payment lookup index concurrently. | +| `20260920120000` | Provider expense records. | +| `20260921010000` | Add retention support to existing dashboard schemas; safe when the initial dashboard migration already includes it. | +| `20260921020000` | Add the attribution-owner lookup index concurrently on existing databases. | +| `20260921020100` | Create the usage metadata actor index concurrently. | +| `20261001000000` | Remove deleted-user attribution from dashboard activity on future user deletion; schema-only upgrade without rewriting historical rows. | + +Do not remove an applied migration after folding its changes into an earlier +schema definition. Existing databases retain its version in `_sqlx_migrations` +and do not rerun earlier versions when their SQL changes. Preserve that history +and provide incremental migrations for any remaining schema differences. + +These migrations do not backfill historical requests. Dashboard totals start at +the stored activation boundary. Background maintenance compacts dashboard minute +details older than 35 days in bounded batches, preserving cumulative totals and +the narrow activity counts; it does not delete source usage. JSONL backups include +the dashboard snapshot and its integrity manifest so retained totals can survive +restoration after source usage has expired. + +Schema migrations and historical backfills remain separate phases. Normal `auto` +startup and `db prepare` still apply pending scripts from `backfills/postgres` +after schema migration; `verify-only` still requires both phases to be current. +The statistics schema migrations above do not embed a historical data rebuild. +The new dashboard's activation boundary is not moved by legacy backfills, so +they do not restore pre-activation dashboard totals. + +Deleted users are excluded from dashboard active-user reads even when an older +version left orphan activity rows. The anonymization upgrade installs rules for +future deletions without cleaning old rows during migration; those old activity +rows age out through the existing 35-day retention task. + +The overview worker can still rebuild a historical hour/day when normal writes +change facts in that bucket. That work runs after startup with bounded batches +and query deadlines; it is not a full historical rebuild during migration. + +The migration runner defaults to a 1-second lock wait, a 10-second deadline per +transactional migration, and a 15-minute deadline per concurrent index migration. +Timeouts are configurable through `AETHER_POSTGRES_MIGRATION_LOCK_TIMEOUT_MS`, +`AETHER_POSTGRES_MIGRATION_TIMEOUT_MS`, and +`AETHER_POSTGRES_MIGRATION_CONCURRENT_TIMEOUT_MS`; none accepts zero. An independent +control connection attempts to terminate the migration session on failure or +cancellation. An interrupted concurrent index build can leave an invalid index; +the runner removes that index before retrying its migration. + +For Compose deployments, `update.sh` applies schema migrations with the new image +before replacing the running app. A migration failure stops the update; already +committed migrations remain applied. Its `local-build` mode delegates to +`deploy.sh` and does not use this separate migration step. Allow for brief table +locks and I/O pressure from concurrent index scans during the upgrade. Keeping +historical backfills out of schema migrations does not make index creation +constant-time: concurrent indexes still scan existing rows and can take minutes +on a large database. The existing app stays running during the Compose migration +preflight. diff --git a/crates/aether-data/runtime/schema/bootstrap/postgres/001_types_and_tables.sql b/crates/aether-data/runtime/schema/bootstrap/postgres/001_types_and_tables.sql index 1f3837fad..0db6c2360 100644 --- a/crates/aether-data/runtime/schema/bootstrap/postgres/001_types_and_tables.sql +++ b/crates/aether-data/runtime/schema/bootstrap/postgres/001_types_and_tables.sql @@ -18,10 +18,7 @@ -- Runs before 20260403000000_baseline.sql so that fresh databases have a -- complete schema by the time baseline (a no-op handoff point) and all -- later ADD COLUMN IF NOT EXISTS migrations execute. -SET statement_timeout = 0; - -SET lock_timeout = 0; - +-- Keep the migration runner's statement and lock deadlines in effect. SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; diff --git a/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql b/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql new file mode 100644 index 000000000..996532093 --- /dev/null +++ b/crates/aether-data/runtime/schema/bootstrap/postgres/190_overview_analytics.sql @@ -0,0 +1,276 @@ +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_origin text; +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_stage text; +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_reason text; +ALTER TABLE public.usage ADD COLUMN IF NOT EXISTS failure_schema_version integer; +ALTER TABLE public.usage_settlement_snapshots + ADD COLUMN IF NOT EXISTS quota_covered_amount_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_consumed_amount_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_debit_amount_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_recharge_debit_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_gift_debit_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS wallet_overdraft_usd numeric(20,8), + ADD COLUMN IF NOT EXISTS allocation_schema_version integer, + ADD COLUMN IF NOT EXISTS allocation_status text; + +CREATE TABLE IF NOT EXISTS public.usage_attribution_snapshots ( + request_id text PRIMARY KEY, + actor_user_id text, + credential_owner_id text, + attribution_kind text NOT NULL DEFAULT 'unknown', + attribution_source text NOT NULL DEFAULT 'unknown', + record_kind text NOT NULL DEFAULT 'request', + parent_request_id text, + schema_version integer NOT NULL DEFAULT 1, + attribution_revision bigint NOT NULL DEFAULT 1, + recorded_at timestamptz NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS ix_usage_attribution_actor_request + ON public.usage_attribution_snapshots(actor_user_id, request_id); +-- This attribution table is new and empty; build both lookup indexes here. +CREATE INDEX IF NOT EXISTS ix_usage_attribution_owner_request + ON public.usage_attribution_snapshots(credential_owner_id, request_id); + +CREATE TABLE IF NOT EXISTS public.stats_bucket_state ( + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamptz NOT NULL, + source_revision bigint NOT NULL DEFAULT 0, + built_revision bigint NOT NULL DEFAULT -1, + coverage_status text NOT NULL DEFAULT 'unbuilt', + built_at timestamptz, + last_error text, + last_failed_at timestamptz, + PRIMARY KEY (projection_version, granularity, bucket_start) +); +CREATE INDEX IF NOT EXISTS ix_stats_bucket_state_dirty + ON public.stats_bucket_state(bucket_start) + WHERE source_revision > built_revision; + +CREATE TABLE IF NOT EXISTS public.stats_overview_hourly ( + projection_version text NOT NULL, + bucket_start timestamptz NOT NULL, + dimensions jsonb NOT NULL, + metrics jsonb NOT NULL, + PRIMARY KEY(projection_version, bucket_start, dimensions) +); +CREATE TABLE IF NOT EXISTS public.stats_overview_daily ( + projection_version text NOT NULL, + bucket_start timestamptz NOT NULL, + dimensions jsonb NOT NULL, + metrics jsonb NOT NULL, + PRIMARY KEY(projection_version, bucket_start, dimensions) +); + +-- These triggers cover old writers, delayed settlement, and maintenance in the fact transaction. +-- Only future fact mutations enqueue work; no historical rows are backfilled. +-- Each writer owns its transaction's keys, so unrelated requests never contend +-- on the current hour/day's stats_bucket_state row. +CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events ( + transaction_id bigint NOT NULL, + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamptz NOT NULL, + unrecoverable boolean NOT NULL DEFAULT false, + PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start) +); +CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket + ON public.stats_overview_dirty_events(projection_version, granularity, bucket_start); + +CREATE OR REPLACE FUNCTION public.overview_mark_usage_bucket() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE old_time timestamptz; new_time timestamptz; bucket record; +BEGIN + IF TG_TABLE_NAME = 'usage' THEN + IF TG_OP <> 'INSERT' THEN old_time := OLD.created_at; END IF; + IF TG_OP <> 'DELETE' THEN new_time := NEW.created_at; END IF; + ELSE + IF TG_OP <> 'INSERT' THEN + SELECT created_at INTO old_time FROM public.usage WHERE request_id = OLD.request_id; + END IF; + IF TG_OP <> 'DELETE' THEN + SELECT created_at INTO new_time FROM public.usage WHERE request_id = NEW.request_id; + END IF; + END IF; + FOR bucket IN + SELECT DISTINCT g, date_trunc(g, t AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS starts + FROM unnest(ARRAY[old_time, new_time]) t CROSS JOIN unnest(ARRAY['day','hour']) g + WHERE t IS NOT NULL ORDER BY g, starts + LOOP + INSERT INTO public.stats_overview_dirty_events + (transaction_id, projection_version, granularity, bucket_start, unrecoverable) + VALUES (txid_current(), 'overview-v2', bucket.g, bucket.starts, + TG_TABLE_NAME = 'usage' AND TG_OP = 'DELETE') + ON CONFLICT (transaction_id, projection_version, granularity, bucket_start) + DO UPDATE SET unrecoverable = stats_overview_dirty_events.unrecoverable OR EXCLUDED.unrecoverable; + END LOOP; + RETURN NULL; +END $$; + +DROP TRIGGER IF EXISTS overview_usage_dirty ON public.usage; +CREATE TRIGGER overview_usage_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket(); +DROP TRIGGER IF EXISTS overview_settlement_dirty ON public.usage_settlement_snapshots; +CREATE TRIGGER overview_settlement_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_settlement_snapshots + FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket(); +DROP TRIGGER IF EXISTS overview_attribution_dirty ON public.usage_attribution_snapshots; +CREATE TRIGGER overview_attribution_dirty AFTER INSERT OR UPDATE OR DELETE ON public.usage_attribution_snapshots + FOR EACH ROW EXECUTE FUNCTION public.overview_mark_usage_bucket(); + +CREATE OR REPLACE FUNCTION public.overview_capture_usage_identity() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE actor text; owner text; kind text; source text; standalone boolean; +BEGIN + SELECT id INTO owner FROM public.users WHERE id=NEW.user_id AND NOT is_deleted; + SELECT COALESCE(k.is_standalone, + CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean' + THEN (NEW.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END, + CASE WHEN jsonb_typeof(NEW.request_metadata::jsonb->'api_key_is_standalone')='boolean' + THEN (NEW.request_metadata->>'api_key_is_standalone')::boolean END, + CASE WHEN a.attribution_source='user_account' THEN false + WHEN a.attribution_source='standalone_key' THEN true END, + CASE WHEN NEW.api_key_id IS NULL THEN false END) + INTO standalone FROM (SELECT 1) seed + LEFT JOIN public.api_keys k ON k.id=NEW.api_key_id + LEFT JOIN public.usage_attribution_snapshots a ON a.request_id=NEW.request_id; + kind := CASE WHEN owner IS NULL THEN 'unknown' WHEN standalone THEN 'standalone' + WHEN NOT standalone THEN 'employee' ELSE 'unknown' END; + actor := CASE WHEN kind='employee' THEN owner END; + source := CASE kind WHEN 'employee' THEN 'user_account' WHEN 'standalone' THEN 'standalone_key' ELSE 'unknown' END; + INSERT INTO public.usage_attribution_snapshots(request_id, actor_user_id, credential_owner_id, + attribution_kind, attribution_source, record_kind, parent_request_id, attribution_revision) + VALUES (NEW.request_id, actor, owner, kind, source, + COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', 'request'), + NEW.request_metadata #>> '{analytics_attribution,parent_request_id}', 2) + ON CONFLICT (request_id) DO UPDATE SET actor_user_id = EXCLUDED.actor_user_id, + credential_owner_id = EXCLUDED.credential_owner_id, attribution_kind = EXCLUDED.attribution_kind, + attribution_source = EXCLUDED.attribution_source, + record_kind = COALESCE(NEW.request_metadata #>> '{analytics_attribution,record_kind}', usage_attribution_snapshots.record_kind), + parent_request_id = COALESCE(EXCLUDED.parent_request_id, usage_attribution_snapshots.parent_request_id), + attribution_revision = EXCLUDED.attribution_revision, recorded_at = NOW() + WHERE usage_attribution_snapshots.attribution_revision <= EXCLUDED.attribution_revision; + RETURN NULL; +END $$; +DROP TRIGGER IF EXISTS overview_usage_identity ON public.usage; +CREATE TRIGGER overview_usage_identity AFTER INSERT OR UPDATE OF user_id, api_key_id, request_metadata ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_capture_usage_identity(); + +CREATE OR REPLACE FUNCTION public.overview_capture_failure() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.status = 'completed' THEN + NEW.failure_origin := NULL; NEW.failure_stage := NULL; NEW.failure_reason := NULL; + NEW.failure_schema_version := NULL; RETURN NEW; + END IF; + IF NEW.request_metadata #>> '{analytics_failure,origin}' IN ('client','gateway','upstream','transport','unknown') THEN + NEW.failure_origin := NEW.request_metadata #>> '{analytics_failure,origin}'; + NEW.failure_stage := NEW.request_metadata #>> '{analytics_failure,stage}'; + NEW.failure_reason := NEW.request_metadata #>> '{analytics_failure,reason}'; + NEW.failure_schema_version := 1; + END IF; + RETURN NEW; +END $$; +DROP TRIGGER IF EXISTS overview_usage_failure ON public.usage; +CREATE TRIGGER overview_usage_failure BEFORE INSERT OR UPDATE OF request_metadata, status ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_capture_failure(); + +CREATE OR REPLACE FUNCTION public.overview_anonymize_user() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' OR NEW.is_deleted THEN + UPDATE public.usage_attribution_snapshots SET actor_user_id = NULL, credential_owner_id = NULL, + attribution_kind = 'unknown', attribution_source = 'unknown', attribution_revision = attribution_revision + 10 + WHERE actor_user_id = OLD.id OR credential_owner_id = OLD.id; + UPDATE public.usage SET request_metadata = (request_metadata::jsonb #- '{analytics_attribution,actor_user_id}')::json + WHERE request_metadata #>> '{analytics_attribution,actor_user_id}' = OLD.id; + DELETE FROM public.stats_overview_hourly WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id; + DELETE FROM public.stats_overview_daily WHERE dimensions->>'actor_user_id'=OLD.id OR dimensions->>'credential_owner_id'=OLD.id; + END IF; + RETURN NULL; +END $$; +DROP TRIGGER IF EXISTS overview_user_anonymize ON public.users; +CREATE TRIGGER overview_user_anonymize AFTER DELETE OR UPDATE OF is_deleted ON public.users + FOR EACH ROW EXECUTE FUNCTION public.overview_anonymize_user(); + +CREATE OR REPLACE FUNCTION public.overview_delete_attribution() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + DELETE FROM public.usage_attribution_snapshots WHERE request_id = OLD.request_id; + RETURN OLD; +END $$; +DROP TRIGGER IF EXISTS overview_usage_delete_attribution ON public.usage; +CREATE TRIGGER overview_usage_delete_attribution BEFORE DELETE ON public.usage + FOR EACH ROW EXECUTE FUNCTION public.overview_delete_attribution(); + +CREATE OR REPLACE VIEW public.usage_analytics_facts_v1 AS +SELECT u.request_id, COALESCE(u.id, u.request_id) AS id, u.created_at, + CASE WHEN identity.owner_id IS NOT NULL AND identity.is_standalone=false THEN identity.owner_id END AS actor_user_id, + identity.owner_id AS credential_owner_id, + CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone' + WHEN NOT identity.is_standalone THEN 'employee' ELSE 'unknown' END AS attribution_kind, + CASE WHEN identity.owner_id IS NULL THEN 'unknown' WHEN identity.is_standalone THEN 'standalone_key' + WHEN NOT identity.is_standalone THEN 'user_account' ELSE 'unknown' END AS attribution_source, + COALESCE(a.record_kind, 'request') AS record_kind, a.parent_request_id, + u.api_key_id, u.model, u.target_model, u.provider_id, u.provider_name, + u.api_format, u.endpoint_kind, u.request_type, u.is_stream, u.has_format_conversion, + u.status, u.status_code, u.error_category, u.failure_origin, u.failure_stage, u.failure_reason, + u.failure_schema_version, u.response_time_ms, u.first_byte_time_ms, + COALESCE(s.billing_status, u.billing_status) AS settlement_status, + COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb AS usage_available, + COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') AS pricing_available, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.input_tokens END AS input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.output_tokens END AS output_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.total_tokens END AS total_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.cache_read_input_tokens END AS cache_read_input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available', 'true'::jsonb) <> 'false'::jsonb + THEN b.cache_creation_input_tokens END AS cache_creation_input_tokens, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_total_cost_usd::numeric, u.total_cost_usd::numeric), 8) END AS rated_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_pricing_available', 'true'::jsonb) <> 'false'::jsonb + AND (s.billing_actual_total_cost_usd IS NOT NULL OR COALESCE(s.billing_status, u.billing_status) = 'settled') + THEN round(COALESCE(s.billing_actual_total_cost_usd::numeric, u.actual_total_cost_usd::numeric), 8) END AS billable_amount, + s.quota_covered_amount_usd AS quota_covered_amount, + s.wallet_consumed_amount_usd AS wallet_consumed_amount, + s.wallet_debit_amount_usd AS wallet_debit_amount, + s.wallet_recharge_debit_usd AS wallet_recharge_debit_amount, + s.wallet_gift_debit_usd AS wallet_gift_debit_amount, + s.wallet_overdraft_usd AS wallet_overdraft_amount, + s.allocation_status, s.finalized_at AS settled_at, + CASE WHEN s.billing_total_cost_usd IS NOT NULL THEN 'settlement_snapshot' ELSE 'legacy_float' END AS amount_source, + b.upstream_is_stream, + CASE WHEN u.request_metadata #>> '{analytics_measurement,source}' IN ('reported','estimated','mixed') + THEN u.request_metadata #>> '{analytics_measurement,source}' ELSE 'unknown' END AS token_source, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL + THEN round(s.input_price_per_1m::numeric * b.cache_read_input_tokens::numeric / 1000000,8) END AS cache_estimated_full_cost_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_read_cost_usd IS NOT NULL + THEN round(s.billing_cache_read_cost_usd::numeric,8) END AS cache_read_cost_amount, + CASE WHEN COALESCE(u.request_metadata::jsonb->'usage_available','true'::jsonb) <> 'false'::jsonb + AND COALESCE(u.request_metadata::jsonb->'usage_pricing_available','true'::jsonb) <> 'false'::jsonb + AND s.input_price_per_1m IS NOT NULL AND s.billing_cache_creation_cost_usd IS NOT NULL + THEN round(s.billing_cache_creation_cost_usd::numeric,8) END AS cache_creation_cost_amount +FROM public.usage u +LEFT JOIN public.usage_settlement_snapshots s USING (request_id) +LEFT JOIN public.usage_attribution_snapshots a USING (request_id) +JOIN public.usage_billing_facts b USING (request_id) +LEFT JOIN public.api_keys k ON k.id=u.api_key_id +CROSS JOIN LATERAL ( + SELECT CASE WHEN a.request_id IS NOT NULL THEN a.credential_owner_id + WHEN EXISTS (SELECT 1 FROM public.users WHERE id=u.user_id AND NOT is_deleted) THEN u.user_id END AS owner_id, + COALESCE(k.is_standalone, + CASE WHEN jsonb_typeof(u.request_metadata::jsonb #> '{analytics_attribution,is_standalone}')='boolean' + THEN (u.request_metadata #>> '{analytics_attribution,is_standalone}')::boolean END, + CASE WHEN jsonb_typeof(u.request_metadata::jsonb->'api_key_is_standalone')='boolean' + THEN (u.request_metadata->>'api_key_is_standalone')::boolean END, + CASE WHEN a.attribution_source='user_account' THEN false + WHEN a.attribution_source='standalone_key' THEN true END, + CASE WHEN u.api_key_id IS NULL THEN false END) AS is_standalone +) identity; diff --git a/crates/aether-data/runtime/schema/bootstrap/postgres/210_provider_expenses.sql b/crates/aether-data/runtime/schema/bootstrap/postgres/210_provider_expenses.sql new file mode 100644 index 000000000..3e770bc5d --- /dev/null +++ b/crates/aether-data/runtime/schema/bootstrap/postgres/210_provider_expenses.sql @@ -0,0 +1,25 @@ +-- Manual purchasing ledger; no foreign-key cascade may erase historical expenditures. +CREATE TABLE IF NOT EXISTS public.provider_expenses ( + id text PRIMARY KEY, + client_request_id text NOT NULL UNIQUE, + provider_id text NOT NULL, + provider_name text NOT NULL, + kind text NOT NULL CHECK (kind IN ('recharge', 'subscription', 'other')), + amount numeric(20,8) NOT NULL CHECK (amount > 0), + currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'), + paid_at timestamptz NOT NULL, + period_start timestamptz, + period_end timestamptz, + note text, + external_reference text, + created_by text, + created_at timestamptz NOT NULL DEFAULT NOW(), + voided_at timestamptz, + voided_by text, + CONSTRAINT provider_expenses_period_check CHECK ( + (period_start IS NULL AND period_end IS NULL) OR + (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end) + ) +); +CREATE INDEX IF NOT EXISTS ix_provider_expenses_paid_at ON public.provider_expenses(paid_at, id); +CREATE INDEX IF NOT EXISTS ix_provider_expenses_provider_paid_at ON public.provider_expenses(provider_id, paid_at); diff --git a/crates/aether-data/runtime/schema/bootstrap/postgres/manifest.txt b/crates/aether-data/runtime/schema/bootstrap/postgres/manifest.txt index ff1eee7fb..fea9c9ff5 100644 --- a/crates/aether-data/runtime/schema/bootstrap/postgres/manifest.txt +++ b/crates/aether-data/runtime/schema/bootstrap/postgres/manifest.txt @@ -13,3 +13,4 @@ 160_routing_profiles.sql 170_usage_legacy_body_ref_cleanup_index.sql 180_usage_stale_pending_cleanup_index.sql +210_provider_expenses.sql diff --git a/crates/aether-data/runtime/schema/drivers/postgres/baseline/001_types_and_tables.sql b/crates/aether-data/runtime/schema/drivers/postgres/baseline/001_types_and_tables.sql index a1f597f3b..6d9d45a95 100644 --- a/crates/aether-data/runtime/schema/drivers/postgres/baseline/001_types_and_tables.sql +++ b/crates/aether-data/runtime/schema/drivers/postgres/baseline/001_types_and_tables.sql @@ -18,10 +18,7 @@ -- Runs before 20260403000000_baseline.sql so that fresh databases have a -- complete schema by the time baseline (a no-op handoff point) and all -- later ADD COLUMN IF NOT EXISTS migrations execute. -SET statement_timeout = 0; - -SET lock_timeout = 0; - +-- Keep the migration runner's statement and lock deadlines in effect. SET idle_in_transaction_session_timeout = 0; SET client_encoding = 'UTF8'; diff --git a/crates/aether-data/runtime/schema/generated/postgres/baseline/005_wallet_billing.sql b/crates/aether-data/runtime/schema/generated/postgres/baseline/005_wallet_billing.sql index da83615e5..680556a5d 100644 --- a/crates/aether-data/runtime/schema/generated/postgres/baseline/005_wallet_billing.sql +++ b/crates/aether-data/runtime/schema/generated/postgres/baseline/005_wallet_billing.sql @@ -104,6 +104,7 @@ ALTER TABLE ONLY public.payment_orders ADD CONSTRAINT uq_payment_orders_order_no CREATE INDEX IF NOT EXISTS idx_payment_orders_wallet_created ON public.payment_orders USING btree (wallet_id, created_at); CREATE INDEX IF NOT EXISTS idx_payment_orders_user_created ON public.payment_orders USING btree (user_id, created_at); CREATE INDEX IF NOT EXISTS idx_payment_orders_status ON public.payment_orders USING btree (status); +CREATE INDEX IF NOT EXISTS idx_payment_orders_status_credited_user ON public.payment_orders USING btree (status, credited_at, user_id); CREATE INDEX IF NOT EXISTS idx_payment_orders_gateway_order_id ON public.payment_orders USING btree (gateway_order_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_payment_orders_payment_method_gateway_order_id ON public.payment_orders USING btree (payment_method, gateway_order_id); CREATE INDEX IF NOT EXISTS idx_payment_orders_kind_status ON public.payment_orders USING btree (order_kind, status); diff --git a/crates/aether-data/runtime/schema/generated/postgres/baseline/006_usage.sql b/crates/aether-data/runtime/schema/generated/postgres/baseline/006_usage.sql index 173d7b7de..cbc7743c3 100644 --- a/crates/aether-data/runtime/schema/generated/postgres/baseline/006_usage.sql +++ b/crates/aether-data/runtime/schema/generated/postgres/baseline/006_usage.sql @@ -99,7 +99,11 @@ CREATE TABLE IF NOT EXISTS public.usage ( wallet_gift_balance_after double precision, finalized_at bigint, created_at_unix_ms bigint DEFAULT 0 NOT NULL, - updated_at_unix_secs bigint DEFAULT 0 NOT NULL + updated_at_unix_secs bigint DEFAULT 0 NOT NULL, + failure_origin text, + failure_stage text, + failure_reason text, + failure_schema_version integer ); ALTER TABLE ONLY public.usage ADD CONSTRAINT usage_pkey PRIMARY KEY (request_id); @@ -204,6 +208,14 @@ CREATE INDEX IF NOT EXISTS ix_usage_counter_deltas_request_kind ON public.usage_ CREATE TABLE IF NOT EXISTS public.usage_settlement_snapshots ( request_id character varying(128) NOT NULL, billing_status character varying(64) NOT NULL, + quota_covered_amount_usd numeric(20,8), + wallet_consumed_amount_usd numeric(20,8), + wallet_debit_amount_usd numeric(20,8), + wallet_recharge_debit_usd numeric(20,8), + wallet_gift_debit_usd numeric(20,8), + wallet_overdraft_usd numeric(20,8), + allocation_schema_version integer, + allocation_status text, wallet_id character varying(64), wallet_balance_before double precision, wallet_balance_after double precision, diff --git a/crates/aether-data/runtime/schema/generated/postgres/baseline/009_overview.sql b/crates/aether-data/runtime/schema/generated/postgres/baseline/009_overview.sql new file mode 100644 index 000000000..477b33af7 --- /dev/null +++ b/crates/aether-data/runtime/schema/generated/postgres/baseline/009_overview.sql @@ -0,0 +1,63 @@ +-- Generated by aether-data-schema from crates/aether-data/runtime/schema/logical/*.toml. +-- Do not edit generated files directly; edit logical schema or explicit overrides instead. + +CREATE TABLE IF NOT EXISTS public.stats_bucket_state ( + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamp with time zone NOT NULL, + source_revision bigint DEFAULT 0 NOT NULL, + built_revision bigint DEFAULT -1 NOT NULL, + coverage_status text DEFAULT 'unbuilt' NOT NULL, + built_at timestamp with time zone, + last_error text, + last_failed_at timestamp with time zone +); + +ALTER TABLE ONLY public.stats_bucket_state ADD CONSTRAINT stats_bucket_state_pkey PRIMARY KEY (projection_version, granularity, bucket_start); + +CREATE TABLE IF NOT EXISTS public.stats_overview_daily ( + projection_version text NOT NULL, + bucket_start timestamp with time zone NOT NULL, + dimensions jsonb NOT NULL, + metrics jsonb NOT NULL +); + +ALTER TABLE ONLY public.stats_overview_daily ADD CONSTRAINT stats_overview_daily_pkey PRIMARY KEY (projection_version, bucket_start, dimensions); + +CREATE TABLE IF NOT EXISTS public.stats_overview_dirty_events ( + transaction_id bigint NOT NULL, + projection_version text NOT NULL, + granularity text NOT NULL, + bucket_start timestamp with time zone NOT NULL, + unrecoverable boolean DEFAULT false NOT NULL +); + +ALTER TABLE ONLY public.stats_overview_dirty_events ADD CONSTRAINT stats_overview_dirty_events_pkey PRIMARY KEY (transaction_id, projection_version, granularity, bucket_start); +CREATE INDEX IF NOT EXISTS ix_stats_overview_dirty_events_bucket ON public.stats_overview_dirty_events USING btree (projection_version, granularity, bucket_start); + +CREATE TABLE IF NOT EXISTS public.stats_overview_hourly ( + projection_version text NOT NULL, + bucket_start timestamp with time zone NOT NULL, + dimensions jsonb NOT NULL, + metrics jsonb NOT NULL +); + +ALTER TABLE ONLY public.stats_overview_hourly ADD CONSTRAINT stats_overview_hourly_pkey PRIMARY KEY (projection_version, bucket_start, dimensions); + +CREATE TABLE IF NOT EXISTS public.usage_attribution_snapshots ( + request_id text NOT NULL, + actor_user_id text, + credential_owner_id text, + attribution_kind text DEFAULT 'unknown' NOT NULL, + attribution_source text DEFAULT 'unknown' NOT NULL, + record_kind text DEFAULT 'request' NOT NULL, + parent_request_id text, + schema_version integer DEFAULT 1 NOT NULL, + attribution_revision bigint DEFAULT 1 NOT NULL, + recorded_at timestamp with time zone DEFAULT NOW() NOT NULL +); + +ALTER TABLE ONLY public.usage_attribution_snapshots ADD CONSTRAINT usage_attribution_snapshots_pkey PRIMARY KEY (request_id); +CREATE INDEX IF NOT EXISTS ix_usage_attribution_actor_request ON public.usage_attribution_snapshots USING btree (actor_user_id, request_id); +CREATE INDEX IF NOT EXISTS ix_usage_attribution_owner_request ON public.usage_attribution_snapshots USING btree (credential_owner_id, request_id); + diff --git a/crates/aether-data/runtime/schema/generated/postgres/baseline/010_dashboard.sql b/crates/aether-data/runtime/schema/generated/postgres/baseline/010_dashboard.sql new file mode 100644 index 000000000..31d61ed3a --- /dev/null +++ b/crates/aether-data/runtime/schema/generated/postgres/baseline/010_dashboard.sql @@ -0,0 +1,84 @@ +-- Generated by aether-data-schema from crates/aether-data/runtime/schema/logical/*.toml. +-- Do not edit generated files directly; edit logical schema or explicit overrides instead. + +CREATE TABLE IF NOT EXISTS public.dashboard_activity_hour ( + bucket_start timestamp with time zone NOT NULL, + shard smallint NOT NULL, + request_count bigint NOT NULL +); + +ALTER TABLE ONLY public.dashboard_activity_hour ADD CONSTRAINT dashboard_activity_hour_pkey PRIMARY KEY (bucket_start, shard); + +CREATE TABLE IF NOT EXISTS public.dashboard_activity_minute ( + bucket_start timestamp with time zone NOT NULL, + shard smallint NOT NULL, + request_count bigint NOT NULL +); + +ALTER TABLE ONLY public.dashboard_activity_minute ADD CONSTRAINT dashboard_activity_minute_pkey PRIMARY KEY (bucket_start, shard); + +CREATE TABLE IF NOT EXISTS public.dashboard_actor_minute ( + bucket_start timestamp with time zone NOT NULL, + shard smallint NOT NULL, + actor_user_id character varying(255) NOT NULL, + request_count bigint NOT NULL +); + +ALTER TABLE ONLY public.dashboard_actor_minute ADD CONSTRAINT dashboard_actor_minute_pkey PRIMARY KEY (bucket_start, shard, actor_user_id); + +CREATE TABLE IF NOT EXISTS public.dashboard_request_contributions ( + request_id character varying(100) NOT NULL, + created_at timestamp with time zone NOT NULL, + actor_user_id character varying(255), + metrics jsonb NOT NULL +); + +ALTER TABLE ONLY public.dashboard_request_contributions ADD CONSTRAINT dashboard_request_contributions_pkey PRIMARY KEY (request_id); + +CREATE TABLE IF NOT EXISTS public.dashboard_stats_minute ( + bucket_start timestamp with time zone NOT NULL, + shard smallint NOT NULL, + metrics jsonb DEFAULT '{}'::jsonb NOT NULL +); + +ALTER TABLE ONLY public.dashboard_stats_minute ADD CONSTRAINT dashboard_stats_minute_pkey PRIMARY KEY (bucket_start, shard); + +CREATE TABLE IF NOT EXISTS public.dashboard_stats_pending ( + transaction_id bigint NOT NULL, + request_id character varying(100) NOT NULL, + deleted_fact jsonb +); + +ALTER TABLE ONLY public.dashboard_stats_pending ADD CONSTRAINT dashboard_stats_pending_pkey PRIMARY KEY (transaction_id, request_id); + +CREATE TABLE IF NOT EXISTS public.dashboard_stats_state ( + singleton boolean DEFAULT true NOT NULL, + stats_since timestamp with time zone NOT NULL, + contributions_cleanup_cursor character varying(100) +); + +ALTER TABLE ONLY public.dashboard_stats_state ADD CONSTRAINT dashboard_stats_state_pkey PRIMARY KEY (singleton); + +CREATE TABLE IF NOT EXISTS public.dashboard_stats_total ( + shard smallint NOT NULL, + metrics jsonb DEFAULT '{}'::jsonb NOT NULL +); + +ALTER TABLE ONLY public.dashboard_stats_total ADD CONSTRAINT dashboard_stats_total_pkey PRIMARY KEY (shard); + +CREATE TABLE IF NOT EXISTS public.dashboard_user_anonymization_pending ( + transaction_id bigint NOT NULL, + user_id character varying(255) NOT NULL +); + +ALTER TABLE ONLY public.dashboard_user_anonymization_pending ADD CONSTRAINT dashboard_user_anonymization_pending_pkey PRIMARY KEY (transaction_id, user_id); + +CREATE TABLE IF NOT EXISTS public.dashboard_user_events_minute ( + bucket_start timestamp with time zone NOT NULL, + shard smallint NOT NULL, + created_count bigint DEFAULT 0 NOT NULL, + deleted_count bigint DEFAULT 0 NOT NULL +); + +ALTER TABLE ONLY public.dashboard_user_events_minute ADD CONSTRAINT dashboard_user_events_minute_pkey PRIMARY KEY (bucket_start, shard); + diff --git a/crates/aether-data/runtime/schema/generated/postgres/baseline/010_provider_expenses.sql b/crates/aether-data/runtime/schema/generated/postgres/baseline/010_provider_expenses.sql new file mode 100644 index 000000000..a23b911f1 --- /dev/null +++ b/crates/aether-data/runtime/schema/generated/postgres/baseline/010_provider_expenses.sql @@ -0,0 +1,27 @@ +-- Generated by aether-data-schema from crates/aether-data/runtime/schema/logical/*.toml. +-- Do not edit generated files directly; edit logical schema or explicit overrides instead. + +CREATE TABLE IF NOT EXISTS public.provider_expenses ( + id text NOT NULL, + client_request_id text NOT NULL, + provider_id text NOT NULL, + provider_name text NOT NULL, + kind text NOT NULL, + amount numeric(20,8) NOT NULL, + currency text NOT NULL, + paid_at timestamp with time zone NOT NULL, + period_start timestamp with time zone, + period_end timestamp with time zone, + note text, + external_reference text, + created_by text, + created_at timestamp with time zone DEFAULT NOW() NOT NULL, + voided_at timestamp with time zone, + voided_by text +); + +ALTER TABLE ONLY public.provider_expenses ADD CONSTRAINT provider_expenses_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.provider_expenses ADD CONSTRAINT provider_expenses_client_request_id_key UNIQUE (client_request_id); +CREATE INDEX IF NOT EXISTS ix_provider_expenses_paid_at ON public.provider_expenses USING btree (paid_at, id); +CREATE INDEX IF NOT EXISTS ix_provider_expenses_provider_paid_at ON public.provider_expenses USING btree (provider_id, paid_at); + diff --git a/crates/aether-data/runtime/schema/generated/postgres/baseline/manifest.txt b/crates/aether-data/runtime/schema/generated/postgres/baseline/manifest.txt index 32ce47e5f..0bb063594 100644 --- a/crates/aether-data/runtime/schema/generated/postgres/baseline/manifest.txt +++ b/crates/aether-data/runtime/schema/generated/postgres/baseline/manifest.txt @@ -9,3 +9,6 @@ 006_usage.sql 007_stats.sql 008_background_tasks.sql +009_overview.sql +010_dashboard.sql +010_provider_expenses.sql diff --git a/crates/aether-data/runtime/schema/logical/005_wallet_billing.toml b/crates/aether-data/runtime/schema/logical/005_wallet_billing.toml index 99bda2f71..967af8f37 100644 --- a/crates/aether-data/runtime/schema/logical/005_wallet_billing.toml +++ b/crates/aether-data/runtime/schema/logical/005_wallet_billing.toml @@ -426,6 +426,10 @@ columns = ["user_id", "created_at"] name = "idx_payment_orders_status" columns = ["status"] +[[table.payment_orders.indexes]] +name = "idx_payment_orders_status_credited_user" +columns = ["status", "credited_at", "user_id"] + [[table.payment_orders.indexes]] name = "idx_payment_orders_gateway_order_id" columns = ["gateway_order_id"] diff --git a/crates/aether-data/runtime/schema/logical/006_usage.toml b/crates/aether-data/runtime/schema/logical/006_usage.toml index d27fed4b9..53baa9933 100644 --- a/crates/aether-data/runtime/schema/logical/006_usage.toml +++ b/crates/aether-data/runtime/schema/logical/006_usage.toml @@ -928,6 +928,26 @@ columns = ["processed_at", "created_at", "id"] name = "ix_usage_counter_deltas_request_kind" columns = ["request_id", "kind", "target_id"] +[[table.usage.columns]] +name = "failure_origin" +type = "text" +nullable = true + +[[table.usage.columns]] +name = "failure_stage" +type = "text" +nullable = true + +[[table.usage.columns]] +name = "failure_reason" +type = "text" +nullable = true + +[[table.usage.columns]] +name = "failure_schema_version" +type = "int32" +nullable = true + [table.usage_settlement_snapshots] domain = "usage" order = 20 @@ -943,6 +963,52 @@ name = "billing_status" type = "text" length = 64 +[[table.usage_settlement_snapshots.columns]] +name = "quota_covered_amount_usd" +type = "decimal_money" +nullable = true +driver.postgres.type = "numeric(20,8)" + +[[table.usage_settlement_snapshots.columns]] +name = "wallet_consumed_amount_usd" +type = "decimal_money" +nullable = true +driver.postgres.type = "numeric(20,8)" + +[[table.usage_settlement_snapshots.columns]] +name = "wallet_debit_amount_usd" +type = "decimal_money" +nullable = true +driver.postgres.type = "numeric(20,8)" + +[[table.usage_settlement_snapshots.columns]] +name = "wallet_recharge_debit_usd" +type = "decimal_money" +nullable = true +driver.postgres.type = "numeric(20,8)" + +[[table.usage_settlement_snapshots.columns]] +name = "wallet_gift_debit_usd" +type = "decimal_money" +nullable = true +driver.postgres.type = "numeric(20,8)" + +[[table.usage_settlement_snapshots.columns]] +name = "wallet_overdraft_usd" +type = "decimal_money" +nullable = true +driver.postgres.type = "numeric(20,8)" + +[[table.usage_settlement_snapshots.columns]] +name = "allocation_schema_version" +type = "int32" +nullable = true + +[[table.usage_settlement_snapshots.columns]] +name = "allocation_status" +type = "text" +nullable = true + [[table.usage_settlement_snapshots.columns]] name = "wallet_id" type = "text_id" diff --git a/crates/aether-data/runtime/schema/logical/009_overview.toml b/crates/aether-data/runtime/schema/logical/009_overview.toml new file mode 100644 index 000000000..a380d4995 --- /dev/null +++ b/crates/aether-data/runtime/schema/logical/009_overview.toml @@ -0,0 +1,175 @@ +[table.usage_attribution_snapshots] +domain = "usage" +primary_key = ["request_id"] + +[[table.usage_attribution_snapshots.columns]] +name = "request_id" +type = "text" + +[[table.usage_attribution_snapshots.columns]] +name = "actor_user_id" +type = "text" +nullable = true + +[[table.usage_attribution_snapshots.columns]] +name = "credential_owner_id" +type = "text" +nullable = true + +[[table.usage_attribution_snapshots.columns]] +name = "attribution_kind" +type = "text" +default = "unknown" + +[[table.usage_attribution_snapshots.columns]] +name = "attribution_source" +type = "text" +default = "unknown" + +[[table.usage_attribution_snapshots.columns]] +name = "record_kind" +type = "text" +default = "request" + +[[table.usage_attribution_snapshots.columns]] +name = "parent_request_id" +type = "text" +nullable = true + +[[table.usage_attribution_snapshots.columns]] +name = "schema_version" +type = "int32" +default = 1 + +[[table.usage_attribution_snapshots.columns]] +name = "attribution_revision" +type = "int64" +default = 1 + +[[table.usage_attribution_snapshots.columns]] +name = "recorded_at" +type = "timestamp" +default = { raw = "NOW()" } + +[[table.usage_attribution_snapshots.indexes]] +name = "ix_usage_attribution_actor_request" +columns = ["actor_user_id", "request_id"] + +[[table.usage_attribution_snapshots.indexes]] +name = "ix_usage_attribution_owner_request" +columns = ["credential_owner_id", "request_id"] + +[table.stats_bucket_state] +domain = "stats" +primary_key = ["projection_version","granularity","bucket_start"] + +[[table.stats_bucket_state.columns]] +name = "projection_version" +type = "text" + +[[table.stats_bucket_state.columns]] +name = "granularity" +type = "text" + +[[table.stats_bucket_state.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.stats_bucket_state.columns]] +name = "source_revision" +type = "int64" +default = 0 + +[[table.stats_bucket_state.columns]] +name = "built_revision" +type = "int64" +default = -1 + +[[table.stats_bucket_state.columns]] +name = "coverage_status" +type = "text" +default = "unbuilt" + +[[table.stats_bucket_state.columns]] +name = "built_at" +type = "timestamp" +nullable = true + +[[table.stats_bucket_state.columns]] +name = "last_error" +type = "text" +nullable = true + +[[table.stats_bucket_state.columns]] +name = "last_failed_at" +type = "timestamp" +nullable = true + +[table.stats_overview_hourly] +domain = "stats" +primary_key = ["projection_version","bucket_start","dimensions"] + +[[table.stats_overview_hourly.columns]] +name = "projection_version" +type = "text" + +[[table.stats_overview_hourly.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.stats_overview_hourly.columns]] +name = "dimensions" +type = "json" + +[[table.stats_overview_hourly.columns]] +name = "metrics" +type = "json" + +[table.stats_overview_daily] +domain = "stats" +primary_key = ["projection_version","bucket_start","dimensions"] + +[[table.stats_overview_daily.columns]] +name = "projection_version" +type = "text" + +[[table.stats_overview_daily.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.stats_overview_daily.columns]] +name = "dimensions" +type = "json" + +[[table.stats_overview_daily.columns]] +name = "metrics" +type = "json" + +[table.stats_overview_dirty_events] +domain = "stats" +primary_key = ["transaction_id", "projection_version", "granularity", "bucket_start"] + +[[table.stats_overview_dirty_events.columns]] +name = "transaction_id" +type = "int64" + +[[table.stats_overview_dirty_events.columns]] +name = "projection_version" +type = "text" + +[[table.stats_overview_dirty_events.columns]] +name = "granularity" +type = "text" + +[[table.stats_overview_dirty_events.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.stats_overview_dirty_events.columns]] +name = "unrecoverable" +type = "bool" +default = false + +[[table.stats_overview_dirty_events.indexes]] +name = "ix_stats_overview_dirty_events_bucket" +columns = ["projection_version", "granularity", "bucket_start"] diff --git a/crates/aether-data/runtime/schema/logical/010_dashboard.toml b/crates/aether-data/runtime/schema/logical/010_dashboard.toml new file mode 100644 index 000000000..a1f023608 --- /dev/null +++ b/crates/aether-data/runtime/schema/logical/010_dashboard.toml @@ -0,0 +1,187 @@ +# Future-only dashboard aggregates. Trigger functions and activation data live in +# migration 20260919000000_add_future_dashboard_summary.sql; no history is backfilled. + +[table.dashboard_activity_hour] +domain = "stats" +primary_key = ["bucket_start", "shard"] + +[[table.dashboard_activity_hour.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.dashboard_activity_hour.columns]] +name = "shard" +type = "int32" +driver.postgres.type = "smallint" + +[[table.dashboard_activity_hour.columns]] +name = "request_count" +type = "int64" + +[table.dashboard_stats_state] +domain = "stats" +primary_key = ["singleton"] + +[[table.dashboard_stats_state.columns]] +name = "singleton" +type = "bool" +default = true + +[[table.dashboard_stats_state.columns]] +name = "stats_since" +type = "timestamp" + +[[table.dashboard_stats_state.columns]] +name = "contributions_cleanup_cursor" +type = "text" +length = 100 +nullable = true + +[table.dashboard_activity_minute] +domain = "stats" +primary_key = ["bucket_start", "shard"] + +[[table.dashboard_activity_minute.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.dashboard_activity_minute.columns]] +name = "shard" +type = "int32" +driver.postgres.type = "smallint" + +[[table.dashboard_activity_minute.columns]] +name = "request_count" +type = "int64" + +[table.dashboard_request_contributions] +domain = "stats" +primary_key = ["request_id"] + +[[table.dashboard_request_contributions.columns]] +name = "request_id" +type = "text" +length = 100 + +[[table.dashboard_request_contributions.columns]] +name = "created_at" +type = "timestamp" + +[[table.dashboard_request_contributions.columns]] +name = "actor_user_id" +type = "text" +nullable = true +length = 255 + +[[table.dashboard_request_contributions.columns]] +name = "metrics" +type = "json" + +[table.dashboard_stats_total] +domain = "stats" +primary_key = ["shard"] + +[[table.dashboard_stats_total.columns]] +name = "shard" +type = "int32" +driver.postgres.type = "smallint" + +[[table.dashboard_stats_total.columns]] +name = "metrics" +type = "json" +default = { raw = "'{}'::jsonb" } + +[table.dashboard_stats_minute] +domain = "stats" +primary_key = ["bucket_start","shard"] + +[[table.dashboard_stats_minute.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.dashboard_stats_minute.columns]] +name = "shard" +type = "int32" +driver.postgres.type = "smallint" + +[[table.dashboard_stats_minute.columns]] +name = "metrics" +type = "json" +default = { raw = "'{}'::jsonb" } + +[table.dashboard_actor_minute] +domain = "stats" +primary_key = ["bucket_start","shard","actor_user_id"] + +[[table.dashboard_actor_minute.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.dashboard_actor_minute.columns]] +name = "shard" +type = "int32" +driver.postgres.type = "smallint" + +[[table.dashboard_actor_minute.columns]] +name = "actor_user_id" +type = "text" +length = 255 + +[[table.dashboard_actor_minute.columns]] +name = "request_count" +type = "int64" + +[table.dashboard_stats_pending] +domain = "stats" +primary_key = ["transaction_id","request_id"] + +[[table.dashboard_stats_pending.columns]] +name = "transaction_id" +type = "int64" + +[[table.dashboard_stats_pending.columns]] +name = "request_id" +type = "text" +length = 100 + +[[table.dashboard_stats_pending.columns]] +name = "deleted_fact" +type = "json" +nullable = true + +[table.dashboard_user_events_minute] +domain = "stats" +primary_key = ["bucket_start","shard"] + +[[table.dashboard_user_events_minute.columns]] +name = "bucket_start" +type = "timestamp" + +[[table.dashboard_user_events_minute.columns]] +name = "shard" +type = "int32" +driver.postgres.type = "smallint" + +[[table.dashboard_user_events_minute.columns]] +name = "created_count" +type = "int64" +default = 0 + +[[table.dashboard_user_events_minute.columns]] +name = "deleted_count" +type = "int64" +default = 0 + +# Transaction-local events only; no committed rows are exported or restored. +[table.dashboard_user_anonymization_pending] +domain = "stats" +primary_key = ["transaction_id", "user_id"] + +[[table.dashboard_user_anonymization_pending.columns]] +name = "transaction_id" +type = "int64" + +[[table.dashboard_user_anonymization_pending.columns]] +name = "user_id" +type = "text" +length = 255 diff --git a/crates/aether-data/runtime/schema/logical/010_provider_expenses.toml b/crates/aether-data/runtime/schema/logical/010_provider_expenses.toml new file mode 100644 index 000000000..ef61f3a30 --- /dev/null +++ b/crates/aether-data/runtime/schema/logical/010_provider_expenses.toml @@ -0,0 +1,88 @@ +[table.provider_expenses] +domain = "wallet_billing" +primary_key = ["id"] + +[[table.provider_expenses.columns]] +name = "id" +type = "text" + +[[table.provider_expenses.columns]] +name = "client_request_id" +type = "text" + +[[table.provider_expenses.columns]] +name = "provider_id" +type = "text" + +[[table.provider_expenses.columns]] +name = "provider_name" +type = "text" + +[[table.provider_expenses.columns]] +name = "kind" +type = "text" + +[[table.provider_expenses.columns]] +name = "amount" +type = "decimal_money" +driver.postgres.type = "numeric(20,8)" + +[[table.provider_expenses.columns]] +name = "currency" +type = "text" + +[[table.provider_expenses.columns]] +name = "paid_at" +type = "timestamp" + +[[table.provider_expenses.columns]] +name = "period_start" +type = "timestamp" +nullable = true + +[[table.provider_expenses.columns]] +name = "period_end" +type = "timestamp" +nullable = true + +[[table.provider_expenses.columns]] +name = "note" +type = "text" +nullable = true + +[[table.provider_expenses.columns]] +name = "external_reference" +type = "text" +nullable = true + +[[table.provider_expenses.columns]] +name = "created_by" +type = "text" +nullable = true + +[[table.provider_expenses.columns]] +name = "created_at" +type = "timestamp" +default = { raw = "NOW()" } + +[[table.provider_expenses.columns]] +name = "voided_at" +type = "timestamp" +nullable = true + +[[table.provider_expenses.columns]] +name = "voided_by" +type = "text" +nullable = true + +[[table.provider_expenses.uniques]] +name = "provider_expenses_client_request_id_key" +columns = ["client_request_id"] + +[[table.provider_expenses.indexes]] +name = "ix_provider_expenses_paid_at" +columns = ["paid_at", "id"] + +[[table.provider_expenses.indexes]] +name = "ix_provider_expenses_provider_paid_at" +columns = ["provider_id", "paid_at"] diff --git a/crates/aether-data/runtime/src/backend/maintenance.rs b/crates/aether-data/runtime/src/backend/maintenance.rs index 3382f7db3..2fdb83d34 100644 --- a/crates/aether-data/runtime/src/backend/maintenance.rs +++ b/crates/aether-data/runtime/src/backend/maintenance.rs @@ -168,6 +168,26 @@ impl DataBackends { } } + pub async fn rebuild_overview_buckets( + &self, + input: &StatsHourlyAggregationInput, + ) -> Result { + match self.sql_backend() { + Some(backend) => backend.rebuild_overview_buckets(input).await, + None => Ok(0), + } + } + + pub async fn drain_overview_dirty_events( + &self, + now: chrono::DateTime, + ) -> Result { + match self.sql_backend() { + Some(backend) => backend.drain_overview_dirty_events(now).await, + None => Ok(0), + } + } + pub async fn aggregate_stats_hourly( &self, input: &StatsHourlyAggregationInput, @@ -417,6 +437,30 @@ impl<'a> SqlBackendRef<'a> { } } + async fn rebuild_overview_buckets( + self, + input: &StatsHourlyAggregationInput, + ) -> Result { + match self { + #[cfg(feature = "postgres")] + Self::Postgres(postgres) => postgres.rebuild_overview_buckets(input).await, + #[cfg(not(feature = "postgres"))] + Self::Disabled(_) => unreachable!("a SQL backend cannot exist without a driver"), + } + } + + async fn drain_overview_dirty_events( + self, + now: chrono::DateTime, + ) -> Result { + match self { + #[cfg(feature = "postgres")] + Self::Postgres(postgres) => postgres.drain_overview_dirty_events(now).await, + #[cfg(not(feature = "postgres"))] + Self::Disabled(_) => unreachable!("a SQL backend cannot exist without a driver"), + } + } + async fn aggregate_stats_hourly( self, input: &StatsHourlyAggregationInput, diff --git a/crates/aether-data/runtime/src/backend/stats/postgres_hourly/mod.rs b/crates/aether-data/runtime/src/backend/stats/postgres_hourly/mod.rs index b81c9d081..c399afce6 100644 --- a/crates/aether-data/runtime/src/backend/stats/postgres_hourly/mod.rs +++ b/crates/aether-data/runtime/src/backend/stats/postgres_hourly/mod.rs @@ -13,6 +13,32 @@ mod sql; use self::sql::*; impl PostgresBackend { + pub async fn drain_overview_dirty_events( + &self, + now: DateTime, + ) -> Result { + let repository = aether_data_postgres::SqlxUsageReadRepository::new(self.pool().clone()); + let merged = repository.merge_overview_dirty_events().await?; + let retained = repository.maintain_dashboard_projection(now, 1_000).await?; + Ok(merged + u64::from(retained)) + } + + pub async fn rebuild_overview_buckets( + &self, + input: &StatsHourlyAggregationInput, + ) -> Result { + let repository = aether_data_postgres::SqlxUsageReadRepository::new(self.pool().clone()); + let cleaned = repository + .maintain_dashboard_projection(input.aggregated_at, 1_000) + .await?; + let rebuilt = repository + .rebuild_overview_buckets(input.target_hour_utc + chrono::Duration::hours(1), 8) + .await?; + // Retention work uses the worker's existing bounded catch-up loop too, + // so a busy installation can retire more than one batch per hour. + Ok(rebuilt + usize::from(cleaned)) + } + pub async fn aggregate_stats_hourly( &self, input: &StatsHourlyAggregationInput, diff --git a/crates/aether-data/runtime/src/lifecycle/export.rs b/crates/aether-data/runtime/src/lifecycle/export.rs index f16c93afb..9f9991c2d 100644 --- a/crates/aether-data/runtime/src/lifecycle/export.rs +++ b/crates/aether-data/runtime/src/lifecycle/export.rs @@ -15,6 +15,8 @@ use crate::{DataLayerError, DatabaseDriver, SqlDatabaseConfig}; #[cfg(feature = "postgres")] mod postgres; +#[cfg(all(test, feature = "postgres"))] +mod dashboard_snapshot_tests; #[cfg(all(test, feature = "postgres"))] mod tests; @@ -97,6 +99,38 @@ struct AuxiliaryTable { } const AUXILIARY_TABLES: &[AuxiliaryTable] = &[ + AuxiliaryTable { + name: "dashboard_stats_state", + primary_key: &["singleton"], + }, + AuxiliaryTable { + name: "dashboard_stats_total", + primary_key: &["shard"], + }, + AuxiliaryTable { + name: "dashboard_stats_minute", + primary_key: &["bucket_start", "shard"], + }, + AuxiliaryTable { + name: "dashboard_activity_hour", + primary_key: &["bucket_start", "shard"], + }, + AuxiliaryTable { + name: "dashboard_activity_minute", + primary_key: &["bucket_start", "shard"], + }, + AuxiliaryTable { + name: "dashboard_actor_minute", + primary_key: &["bucket_start", "shard", "actor_user_id"], + }, + AuxiliaryTable { + name: "dashboard_user_events_minute", + primary_key: &["bucket_start", "shard"], + }, + AuxiliaryTable { + name: "dashboard_request_contributions", + primary_key: &["request_id"], + }, AuxiliaryTable { name: "audit_logs", primary_key: &["id"], @@ -181,6 +215,10 @@ const AUXILIARY_TABLES: &[AuxiliaryTable] = &[ name: "payment_gateway_configs", primary_key: &["provider"], }, + AuxiliaryTable { + name: "provider_expenses", + primary_key: &["id"], + }, AuxiliaryTable { name: "billing_plans", primary_key: &["id"], @@ -213,6 +251,10 @@ const AUXILIARY_TABLES: &[AuxiliaryTable] = &[ name: "usage_routing_snapshots", primary_key: &["request_id"], }, + AuxiliaryTable { + name: "usage_attribution_snapshots", + primary_key: &["request_id"], + }, AuxiliaryTable { name: "usage_counter_deltas", primary_key: &["id"], @@ -389,6 +431,22 @@ pub struct DataExportManifest { pub created_at_unix_secs: u64, pub source_driver: Option, pub domains: Vec, + /// Complete dashboard projection, restored atomically rather than merged by row. + /// Older exports omit this field and retain their ordinary import behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dashboard_snapshot: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DashboardSnapshotManifest { + pub version: u32, + pub tables: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DashboardSnapshotTable { + pub rows: usize, + pub sha256: String, } impl DataExportManifest { @@ -405,6 +463,7 @@ impl DataExportManifest { created_at_unix_secs, source_driver, domains, + dashboard_snapshot: None, } } } diff --git a/crates/aether-data/runtime/src/lifecycle/export/dashboard_snapshot_tests.rs b/crates/aether-data/runtime/src/lifecycle/export/dashboard_snapshot_tests.rs new file mode 100644 index 000000000..338de8017 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/export/dashboard_snapshot_tests.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::lifecycle::postgres_test_support::ManagedPostgresServer; +use sqlx::PgPool; + +async fn migrate(pool: &PgPool) { + crate::lifecycle::migrate::prepare_database_for_startup(pool) + .await + .unwrap(); + crate::lifecycle::migrate::run_migrations(pool) + .await + .unwrap(); +} + +async fn request(pool: &PgPool, id: &str, input: i32) { + sqlx::query("INSERT INTO usage(id,request_id,user_id,api_key_id,provider_name,model,status,billing_status,created_at,input_tokens,output_tokens,total_tokens) VALUES ($1,$1,'backup-user','backup-key','test','test','completed','settled',clock_timestamp(),$2,3,$2+3)") + .bind(id).bind(input).execute(pool).await.unwrap(); +} + +async fn dashboard_rows(pool: &PgPool) -> BTreeMap> { + let mut result = BTreeMap::new(); + for table in AUXILIARY_TABLES + .iter() + .filter(|table| table.name.starts_with("dashboard_")) + { + let mut rows = + sqlx::query_scalar::<_, Value>(&format!("SELECT to_jsonb(t) FROM {} t", table.name)) + .fetch_all(pool) + .await + .unwrap(); + rows.sort_by_key(Value::to_string); + result.insert(table.name.to_owned(), rows); + } + result +} + +async fn total(pool: &PgPool) -> i64 { + sqlx::query_scalar("SELECT COALESCE(sum((metrics->>'request_count')::bigint),0)::bigint FROM dashboard_stats_total").fetch_one(pool).await.unwrap() +} + +async fn tokens(pool: &PgPool) -> i64 { + sqlx::query_scalar("SELECT COALESCE(sum((metrics->>'total_tokens')::bigint),0)::bigint FROM dashboard_stats_total") + .fetch_one(pool).await.unwrap() +} + +#[tokio::test] +async fn postgres_dashboard_snapshot_roundtrip_preserves_purged_totals_and_future_updates() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let source = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(server.database_url()) + .await + .unwrap(); + migrate(&source).await; + sqlx::raw_sql("INSERT INTO users(id,username,email_verified) VALUES('backup-user','backup-user',false); INSERT INTO api_keys(id,user_id,key_hash) VALUES('backup-key','backup-user',repeat('e',64));").execute(&source).await.unwrap(); + request(&source, "backup-kept", 11).await; + request(&source, "backup-purged", 23).await; + sqlx::query("DELETE FROM usage WHERE request_id='backup-purged'") + .execute(&source) + .await + .unwrap(); + // Simulate the retention worker dropping a contribution whose raw request is gone. + sqlx::query("DELETE FROM dashboard_request_contributions WHERE request_id='backup-purged'") + .execute(&source) + .await + .unwrap(); + assert_eq!(total(&source).await, 2); + assert_eq!(tokens(&source).await, 40); + let expected = dashboard_rows(&source).await; + let export = export_postgres_core_jsonl(&source, 1_800_000_000) + .await + .unwrap(); + let plan = build_import_plan(&export).unwrap(); + assert!(plan.manifest.dashboard_snapshot.is_some()); + assert!(!plan + .rows(ExportDomain::Auxiliary) + .iter() + .any(|row| row.payload["__table"] == "dashboard_stats_pending")); + sqlx::query("CREATE DATABASE dashboard_restore_test") + .execute(&source) + .await + .unwrap(); + let target_url = server + .database_url() + .strip_suffix("/postgres") + .unwrap() + .to_owned() + + "/dashboard_restore_test"; + let target = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&target_url) + .await + .unwrap(); + migrate(&target).await; + // The gateway initializes one verified local admin and a zero-value unlimited + // wallet before the operator can run restore. Preserve that account while + // replacing its one initialization event with the source statistics. + sqlx::raw_sql("INSERT INTO users(id,username,email_verified,role,auth_source,is_active) VALUES('bootstrap-admin','custom-root-name',true,'admin','local',true); INSERT INTO wallets(id,user_id,limit_mode,currency,status,created_at,updated_at) VALUES('bootstrap-wallet','bootstrap-admin','unlimited','USD','active',clock_timestamp(),clock_timestamp());") + .execute(&target).await.unwrap(); + sqlx::query("UPDATE wallets SET balance=1,total_recharged=1 WHERE id='bootstrap-wallet'") + .execute(&target) + .await + .unwrap(); + let error = import_postgres_jsonl(&target, &export).await.unwrap_err(); + assert!( + error + .to_string() + .contains("conflicts with existing statistics"), + "{error}" + ); + sqlx::query("UPDATE wallets SET balance=0,total_recharged=0 WHERE id='bootstrap-wallet'") + .execute(&target) + .await + .unwrap(); + import_postgres_jsonl(&target, &export).await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM users WHERE id='bootstrap-admin'") + .fetch_one(&target) + .await + .unwrap(), + 1, + "the initialized admin account is not removed" + ); + assert_eq!(dashboard_rows(&target).await,expected,"restore includes exact activation timestamp, narrow history, user events and purged request counts"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM usage") + .fetch_one(&target) + .await + .unwrap(), + 1 + ); + import_postgres_jsonl(&target, &export).await.unwrap(); + assert_eq!( + dashboard_rows(&target).await, + expected, + "repeated import must not count users or requests twice" + ); + let mut invalid_source = decode_jsonl(&export).unwrap(); + for record in &mut invalid_source { + if let DataExportRecord::Row { + domain: ExportDomain::Users, + payload, + .. + } = record + { + payload["auth_source"] = Value::String("invalid-auth-source".into()); + } + } + assert!( + import_postgres_jsonl(&target, &encode_jsonl(&invalid_source).unwrap()) + .await + .is_err() + ); + assert_eq!( + dashboard_rows(&target).await, + expected, + "failure after the restore guard and deletes rolls the entire transaction back" + ); + let guard: Option = + sqlx::query_scalar("SELECT current_setting('aether.dashboard_restore',true)") + .fetch_one(&target) + .await + .unwrap(); + assert_ne!(guard.as_deref(), Some("on")); + sqlx::query("UPDATE usage SET input_tokens=17,total_tokens=20 WHERE request_id='backup-kept'") + .execute(&target) + .await + .unwrap(); + assert_eq!( + total(&target).await, + 2, + "existing restored contribution updates by delta" + ); + assert_eq!(tokens(&target).await, 46); + request(&target, "backup-next", 31).await; + assert_eq!( + total(&target).await, + 3, + "future writes resume ordinary aggregation" + ); + assert_eq!(tokens(&target).await, 80); + let before_conflict = dashboard_rows(&target).await; + let error = import_postgres_jsonl(&target, &export).await.unwrap_err(); + assert!( + error + .to_string() + .contains("conflicts with existing statistics"), + "{error}" + ); + assert_eq!( + dashboard_rows(&target).await, + before_conflict, + "conflicting snapshots must roll back without overwriting accumulated data" + ); + let mut truncated = decode_jsonl(&export).unwrap(); + truncated.retain(|row| !matches!(row,DataExportRecord::Row { payload,.. } if payload["__table"]=="dashboard_stats_total" && payload["shard"]==serde_json::json!(1))); + let error = import_postgres_jsonl(&target, &encode_jsonl(&truncated).unwrap()) + .await + .unwrap_err(); + assert!(error.to_string().contains("missing, truncated"), "{error}"); + // A legacy file has neither aggregate rows nor the optional snapshot manifest. + // Its ordinary source import must still trigger aggregation. + let mut legacy = decode_jsonl(&export).unwrap(); + if let DataExportRecord::Manifest { manifest } = &mut legacy[0] { + manifest.dashboard_snapshot = None; + } + legacy.retain(|row| !matches!(row,DataExportRecord::Row { payload,.. } if payload["__table"].as_str().is_some_and(|name| name.starts_with("dashboard_")))); + sqlx::query("CREATE DATABASE dashboard_legacy_restore_test") + .execute(&source) + .await + .unwrap(); + let legacy_url = server + .database_url() + .strip_suffix("/postgres") + .unwrap() + .to_owned() + + "/dashboard_legacy_restore_test"; + let legacy_target = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&legacy_url) + .await + .unwrap(); + migrate(&legacy_target).await; + sqlx::query("UPDATE dashboard_stats_state SET stats_since='2000-01-01'") + .execute(&legacy_target) + .await + .unwrap(); + let legacy_export = encode_jsonl(&legacy).unwrap(); + import_postgres_jsonl(&legacy_target, &legacy_export) + .await + .unwrap(); + assert_eq!(total(&legacy_target).await, 1); + import_postgres_jsonl(&legacy_target, &legacy_export) + .await + .unwrap(); + assert_eq!(total(&legacy_target).await, 1); + legacy_target.close().await; + target.close().await; + source.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/export/postgres.rs b/crates/aether-data/runtime/src/lifecycle/export/postgres.rs index 32e455f8b..3cc64b881 100644 --- a/crates/aether-data/runtime/src/lifecycle/export/postgres.rs +++ b/crates/aether-data/runtime/src/lifecycle/export/postgres.rs @@ -1,5 +1,7 @@ use super::*; +mod dashboard_snapshot; + pub async fn export_postgres_core_jsonl( pool: &crate::driver::postgres::PostgresPool, created_at_unix_secs: u64, @@ -17,6 +19,10 @@ pub async fn export_postgres_jsonl( .execute(&mut *tx) .await .map_sql_err()?; + sqlx::query("SET LOCAL TIME ZONE 'UTC'") + .execute(&mut *tx) + .await + .map_sql_err()?; let manifest = DataExportManifest::new( created_at_unix_secs, Some(DatabaseDriver::Postgres), @@ -51,6 +57,7 @@ pub async fn export_postgres_jsonl( } } + dashboard_snapshot::attach_manifest(&mut records)?; tx.commit().await.map_sql_err()?; encode_jsonl(&records) } @@ -85,6 +92,11 @@ async fn import_postgres_plan_with_options( ) -> Result { let identity_scope = IdentityImportScope::from_plan(plan)?; let mut tx = pool.begin().await.map_sql_err()?; + sqlx::query("SET LOCAL TIME ZONE 'UTC'") + .execute(&mut *tx) + .await + .map_sql_err()?; + dashboard_snapshot::prepare_restore(&mut tx, plan).await?; let identity_state = capture_postgres_identity_import_state(&mut tx, &identity_scope).await?; let mut imported = 0usize; let mut column_cache = BTreeMap::::new(); diff --git a/crates/aether-data/runtime/src/lifecycle/export/postgres/dashboard_snapshot.rs b/crates/aether-data/runtime/src/lifecycle/export/postgres/dashboard_snapshot.rs new file mode 100644 index 000000000..4fcbaa04d --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/export/postgres/dashboard_snapshot.rs @@ -0,0 +1,289 @@ +use super::*; + +fn tables() -> impl Iterator { + AUXILIARY_TABLES + .iter() + .filter(|table| table.name.starts_with("dashboard_")) +} + +fn fingerprint(rows: &[Value]) -> DashboardSnapshotTable { + // Sorting canonical JSON makes fingerprints independent of file row order. + let mut encoded = rows + .iter() + .map(|row| { + let mut canonical = row.clone(); + canonical.sort_all_objects(); + canonical.to_string() + }) + .collect::>(); + encoded.sort(); + let mut digest = Sha256::new(); + for row in encoded { + digest.update(row.as_bytes()); + digest.update(b"\n"); + } + DashboardSnapshotTable { + rows: rows.len(), + sha256: format!("{:x}", digest.finalize()), + } +} + +fn snapshot_rows<'a>( + rows: impl Iterator, +) -> Result>, DataLayerError> { + let mut result = tables() + .map(|table| (table.name.to_owned(), Vec::new())) + .collect::>(); + for row in rows { + let (name, payload) = domain_payload_table(row, "auxiliary", None)?; + if let Some(entries) = result.get_mut(&name) { + entries.push(payload); + } + } + Ok(result) +} + +pub(super) fn attach_manifest(records: &mut [DataExportRecord]) -> Result<(), DataLayerError> { + let Some(DataExportRecord::Manifest { manifest }) = records.first() else { + return Ok(()); + }; + if !manifest.domains.contains(&ExportDomain::Auxiliary) { + return Ok(()); + } + let rows = records + .iter() + .filter_map(|record| match record { + DataExportRecord::Row { + domain: ExportDomain::Auxiliary, + id, + payload, + } => Some(ExportRow { + id: id.clone(), + payload: payload.clone(), + }), + _ => None, + }) + .collect::>(); + let values = snapshot_rows(rows.iter())?; + let tables = values + .into_iter() + .map(|(name, values)| (name, fingerprint(&values))) + .collect(); + if let Some(DataExportRecord::Manifest { manifest }) = records.first_mut() { + manifest.dashboard_snapshot = Some(DashboardSnapshotManifest { version: 1, tables }); + } + Ok(()) +} + +fn invalid(detail: &str) -> DataLayerError { + DataLayerError::InvalidInput(format!("dashboard snapshot {detail}")) +} + +pub(super) async fn prepare_restore( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + plan: &DataImportPlan, +) -> Result<(), DataLayerError> { + let values = snapshot_rows(plan.rows(ExportDomain::Auxiliary).iter())?; + let Some(manifest) = &plan.manifest.dashboard_snapshot else { + if values.values().any(|rows| !rows.is_empty()) { + return Err(invalid( + "requires its complete manifest; partial aggregate imports cannot be merged", + )); + } + return Ok(()); // Legacy backups deliberately keep normal trigger behavior. + }; + if manifest.version != 1 + || !plan.imports_domain(ExportDomain::Auxiliary) + || manifest.tables.len() != values.len() + { + return Err(invalid( + "has an unsupported version or incomplete table inventory", + )); + } + for (name, rows) in &values { + if manifest.tables.get(name) != Some(&fingerprint(rows)) { + return Err(invalid(&format!( + "table '{name}' is missing, truncated, or has changed" + ))); + } + } + let state = &values["dashboard_stats_state"]; + let totals = &values["dashboard_stats_total"]; + let shards = totals + .iter() + .filter_map(|row| row.get("shard").and_then(Value::as_u64)) + .collect::>(); + if state.len() != 1 + || state[0].get("singleton") != Some(&Value::Bool(true)) + || state[0] + .get("stats_since") + .and_then(Value::as_str) + .and_then(parse_imported_datetime) + .is_none() + || totals.len() != 16 + || shards != (0..16).collect() + { + return Err(invalid( + "must include one activation state and all 16 total shards", + )); + } + validate_request_counts(&values)?; + + // Exclude concurrent source writes before examining or replacing projections. + // Their triggers acquire projection locks in this same source-first order. + sqlx::query("LOCK TABLE public.users, public.usage, public.usage_settlement_snapshots, public.usage_attribution_snapshots IN SHARE ROW EXCLUSIVE MODE") + .execute(&mut **tx).await.map_sql_err()?; + // Match retention's shard -> minute -> activity -> actor -> event -> state -> ledger + // order, otherwise a maintenance pass could deadlock against the restore. + for table in [ + "dashboard_stats_total", + "dashboard_stats_minute", + "dashboard_activity_minute", + "dashboard_actor_minute", + "dashboard_user_events_minute", + "dashboard_stats_state", + "dashboard_request_contributions", + "dashboard_activity_hour", + ] { + sqlx::query(&format!( + "LOCK TABLE public.{table} IN SHARE ROW EXCLUSIVE MODE" + )) + .execute(&mut **tx) + .await + .map_sql_err()?; + } + let bootstrap_only = bootstrap_admin_only(tx).await?; + let mut identical = true; + let mut empty = true; + for table in tables() { + let mut current = sqlx::query_scalar::<_, Value>(&format!( + "SELECT to_jsonb(t) FROM public.{} t", + table.name + )) + .fetch_all(&mut **tx) + .await + .map_sql_err()?; + // The bounded cleanup cursor is operational progress, not a change to + // statistics; moving it alone must not make a repeated restore conflict. + if table.name == "dashboard_stats_state" { + for row in &mut current { + if let Some(object) = row.as_object_mut() { + if let Some(cursor) = state[0].get("contributions_cleanup_cursor") { + object.insert("contributions_cleanup_cursor".into(), cursor.clone()); + } + } + } + } + identical &= manifest.tables.get(table.name) == Some(&fingerprint(¤t)); + empty &= match table.name { + "dashboard_stats_state" => true, // A freshly migrated database already has an activation timestamp. + "dashboard_stats_total" => current.iter().all(|row| { + row.get("metrics") + .and_then(Value::as_object) + .is_some_and(|metrics| metrics.values().all(|n| n.as_f64() == Some(0.0))) + }), + "dashboard_user_events_minute" => current.is_empty() || bootstrap_only, + _ => current.is_empty(), + }; + } + if !empty && !identical { + return Err(invalid("conflicts with existing statistics; restore into an empty database. Complete statistics cannot be incrementally merged")); + } + if empty && !identical { + let since = + parse_imported_datetime(state[0]["stats_since"].as_str().expect("validated state")) + .expect("validated timestamp"); + if sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM public.usage WHERE created_at >= $1)", + ) + .bind(since) + .fetch_one(&mut **tx) + .await + .map_sql_err()? + { + return Err(invalid("conflicts with existing requests within the restored activation period; restore into an empty database")); + } + } + // A local custom setting affects only dashboard triggers, not integrity or + // billing triggers, and is automatically reverted on both commit and rollback. + sqlx::query("SET LOCAL aether.dashboard_restore = 'on'") + .execute(&mut **tx) + .await + .map_sql_err()?; + for table in tables() { + sqlx::query(&format!("DELETE FROM public.{}", table.name)) + .execute(&mut **tx) + .await + .map_sql_err()?; + } + Ok(()) +} + +async fn bootstrap_admin_only( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + // bootstrap_admin_from_env has no persistent marker and permits a configured + // username. Recognize only its otherwise untouched single-admin/zero-wallet + // state, never an installation with financial or request history. + sqlx::query("LOCK TABLE public.api_keys, public.wallets, public.payment_orders, public.wallet_transactions, public.provider_expenses IN SHARE ROW EXCLUSIVE MODE") + .execute(&mut **tx).await.map_sql_err()?; + sqlx::query_scalar(r#" + SELECT (SELECT count(*) FROM users)=1 + AND (SELECT count(*) FROM wallets)=1 + AND (SELECT count(*) FROM dashboard_user_events_minute)=1 + AND NOT EXISTS(SELECT 1 FROM usage) + AND NOT EXISTS(SELECT 1 FROM api_keys) + AND NOT EXISTS(SELECT 1 FROM payment_orders) + AND NOT EXISTS(SELECT 1 FROM wallet_transactions) + AND NOT EXISTS(SELECT 1 FROM provider_expenses) + AND EXISTS( + SELECT 1 FROM users u JOIN wallets w ON w.user_id=u.id + JOIN dashboard_user_events_minute e + ON e.bucket_start=date_trunc('minute',u.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + AND e.shard=(hashtextextended(u.id,0)&15)::smallint + JOIN dashboard_stats_state s ON s.singleton + WHERE u.role='admin' AND u.auth_source='local' AND u.is_active + AND NOT u.is_deleted AND u.email_verified AND u.created_at>=s.stats_since + AND w.api_key_id IS NULL AND w.limit_mode='unlimited' + AND w.currency='USD' AND w.status='active' + AND w.balance=0 AND w.gift_balance=0 AND w.total_recharged=0 + AND w.total_consumed=0 AND w.total_refunded=0 AND w.total_adjusted=0 + AND e.created_count=1 AND e.deleted_count=0 + ) + "#).fetch_one(&mut **tx).await.map_sql_err() +} + +fn validate_request_counts(values: &BTreeMap>) -> Result<(), DataLayerError> { + let mut totals = [0u64; 16]; + for row in &values["dashboard_stats_total"] { + let shard = row["shard"] + .as_u64() + .ok_or_else(|| invalid("has an invalid shard"))? as usize; + totals[shard] = request_count(&row["metrics"]["request_count"])?; + } + let mut hours = [0u64; 16]; + for row in &values["dashboard_activity_hour"] { + let shard = row["shard"] + .as_u64() + .filter(|n| *n < 16) + .ok_or_else(|| invalid("has an invalid activity shard"))? as usize; + hours[shard] = hours[shard] + .checked_add(request_count(&row["request_count"])?) + .ok_or_else(|| invalid("activity counts overflow"))?; + } + if hours != totals { + return Err(invalid( + "hourly activity and cumulative request counts disagree", + )); + } + Ok(()) +} + +fn request_count(value: &Value) -> Result { + if value.is_null() { + return Ok(0); + } + value + .as_u64() + .ok_or_else(|| invalid("contains an invalid request count")) +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs index d1549c3d5..682a0d6db 100644 --- a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs @@ -28,6 +28,12 @@ use crate::lifecycle::bootstrap::postgres::{ }; mod policy_nulls; +mod overview_dirty_events; +mod provider_expenses; +mod migration_deadlines; +mod overview_migration_safety; +mod legacy_overview_upgrade; +mod dashboard_user_anonymization; /// A clean PostgreSQL database is bootstrapped from the schema snapshot first; /// migrations after the privacy/security frontier are intentionally left @@ -1575,6 +1581,18 @@ fn pending_migrations_from_applied_skips_versions_already_applied() { 20260901000000, 20260903000000, 20260908000000, + 20260911000000, + 20260917000000, + 20260917000100, + 20260918000000, + 20260918000100, + 20260919000000, + 20260920000000, + 20260920120000, + 20260921010000, + 20260921020000, + 20260921020100, + 20261001000000, ] ); } @@ -1875,6 +1893,169 @@ WHERE id = 'metadata-migration-key' .expect("provider migration fixture should clean up"); } +#[tokio::test] +async fn overview_migrations_support_fresh_and_legacy_install() { + for legacy in [false, true] { + let Some(server) = ManagedPostgresServer::try_start() + .await + .expect("overview PostgreSQL should start or skip") + else { + return; + }; + if legacy { + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + connection.ensure_migrations_table().await.unwrap(); + for migration in POSTGRES_MIGRATOR + .iter() + .filter(|migration| migration.version < 20260911000000) + { + connection.apply(migration).await.unwrap(); + } + sqlx::raw_sql( + r#" +INSERT INTO users(id, username, email_verified) +VALUES ('overview-legacy-owner', 'overview-legacy-owner', false); +INSERT INTO api_keys(id, user_id, key_hash) +VALUES ('overview-legacy-key', 'overview-legacy-owner', repeat('e', 64)); +INSERT INTO usage(id, request_id, user_id, api_key_id, provider_name, model, + status, billing_status, created_at) +VALUES ('overview-legacy-request', 'overview-legacy-request', + 'overview-legacy-owner', 'overview-legacy-key', 'test', 'test', + 'completed', 'settled', '2020-01-01 12:34:00+00'); +"#, + ) + .execute(&mut connection) + .await + .unwrap(); + } + let pool = PgPool::connect(server.database_url()).await.unwrap(); + prepare_and_apply_clean_postgres_database(&pool).await; + for table in [ + "usage_attribution_snapshots", + "stats_bucket_state", + "stats_overview_hourly", + "stats_overview_daily", + ] { + assert!(table_exists(&pool, table).await.unwrap()); + } + assert!(!column_exists(&pool, "api_keys", "credential_kind") + .await + .unwrap()); + assert!( + !column_exists(&pool, "usage_attribution_snapshots", "credential_kind") + .await + .unwrap() + ); + assert!(column_exists(&pool, "stats_bucket_state", "last_failed_at") + .await + .unwrap()); + let precision:(i32,i32)=sqlx::query_as("SELECT numeric_precision::integer,numeric_scale::integer FROM information_schema.columns WHERE table_schema='public' AND table_name='usage_settlement_snapshots' AND column_name='wallet_debit_amount_usd'").fetch_one(&pool).await.unwrap(); + assert_eq!(precision, (20, 8)); + if legacy { + let facts:(Option,Option,String,Option)=sqlx::query_as("SELECT actor_user_id,credential_owner_id,attribution_source,wallet_debit_amount::text FROM usage_analytics_facts_v1 WHERE request_id='overview-legacy-request'").fetch_one(&pool).await.unwrap(); + assert_eq!( + facts, + ( + Some("overview-legacy-owner".into()), + Some("overview-legacy-owner".into()), + "user_account".into(), + None + ) + ); + } + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + let target = chrono::DateTime::parse_from_rfc3339("2026-09-17T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + for _ in 0..2 { + let counts: (i64, i64, i64, i64) = sqlx::query_as( + r#" +SELECT (SELECT COUNT(*) FROM usage_attribution_snapshots), + (SELECT COUNT(*) FROM stats_bucket_state), + (SELECT COUNT(*) FROM stats_overview_hourly), + (SELECT COUNT(*) FROM stats_overview_daily) +"#, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(counts, (0, 0, 0, 0)); + assert_eq!(repo.rebuild_overview_buckets(target, 8).await.unwrap(), 0); + super::run_migrations(&pool).await.unwrap(); + } + + sqlx::raw_sql( + r#" +INSERT INTO users(id, username, email_verified) +VALUES ('overview-new-owner', 'overview-new-owner', false); +INSERT INTO api_keys(id, user_id, key_hash) +VALUES ('overview-new-key', 'overview-new-owner', repeat('f', 64)); +INSERT INTO usage(id, request_id, user_id, api_key_id, provider_name, model, + status, billing_status, created_at, request_metadata) +VALUES ('overview-new-request', 'overview-new-request', + 'overview-new-owner', 'overview-new-key', 'test', 'test', + 'completed', 'settled', '2026-09-15 12:34:00+00', + '{"analytics_attribution":{"is_standalone":false}}'::json); +"#, + ) + .execute(&pool) + .await + .unwrap(); + let identity: (String, String, String, String) = sqlx::query_as( + r#" +SELECT actor_user_id, credential_owner_id, attribution_kind, + attribution_source +FROM usage_attribution_snapshots WHERE request_id = 'overview-new-request' +"#, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + identity, + ( + "overview-new-owner".into(), + "overview-new-owner".into(), + "employee".into(), + "user_account".into(), + ) + ); + assert_eq!(repo.rebuild_overview_buckets(target, 8).await.unwrap(), 2); + assert_eq!(repo.rebuild_overview_buckets(target, 8).await.unwrap(), 0); + let counts: (i64, i64, i64, i64, i64) = sqlx::query_as( + r#" +SELECT (SELECT COUNT(*) FROM usage_attribution_snapshots), + (SELECT COUNT(*) FROM usage_attribution_snapshots + WHERE request_id = 'overview-legacy-request'), + (SELECT COUNT(*) FROM stats_bucket_state), + (SELECT COUNT(*) FROM stats_overview_hourly), + (SELECT COUNT(*) FROM stats_overview_daily) +"#, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(counts, (1, 0, 2, 1, 1)); + let unexpected_bucket_count: i64 = query_scalar( + r#" +SELECT COUNT(*) FROM stats_bucket_state +WHERE projection_version <> 'overview-v2' + OR (granularity, bucket_start) NOT IN ( + ('day', '2026-09-15 00:00:00+00'::timestamptz), + ('hour', '2026-09-15 12:00:00+00'::timestamptz) + ) + OR coverage_status <> 'complete' + OR source_revision <> built_revision +"#, + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(unexpected_bucket_count, 0); + pool.close().await; + } +} + #[tokio::test] async fn prepare_database_for_startup_bootstraps_clean_database() { let Some(server) = ManagedPostgresServer::try_start() @@ -2810,9 +2991,7 @@ WHERE request_id = 'billing-facts-cache-create' } #[tokio::test] -async fn postgres_migrations_repair_invalid_concurrent_cleanup_index() { - const MIGRATION_VERSION: i64 = 20260715000000; - +async fn postgres_migrations_repair_invalid_concurrent_indexes() { let Some(server) = ManagedPostgresServer::try_start() .await .expect("postgres migration retry test should start or skip") @@ -2824,11 +3003,6 @@ async fn postgres_migrations_repair_invalid_concurrent_cleanup_index() { .await .expect("pool should connect"); prepare_and_apply_clean_postgres_database(&pool).await; - - query("DROP INDEX CONCURRENTLY public.idx_usage_legacy_body_ref_cleanup_created_at") - .execute(&pool) - .await - .expect("snapshot cleanup index should exist"); query("CREATE TABLE public.concurrent_index_failure_fixture (value integer NOT NULL)") .execute(&pool) .await @@ -2838,62 +3012,89 @@ async fn postgres_migrations_repair_invalid_concurrent_cleanup_index() { .await .expect("duplicate failure fixtures should be inserted"); - query( - "CREATE UNIQUE INDEX CONCURRENTLY idx_usage_legacy_body_ref_cleanup_created_at ON public.concurrent_index_failure_fixture (value)", - ) - .execute(&pool) - .await - .expect_err("duplicate values should leave a failed concurrent index build"); - - let invalid_index_exists: bool = query_scalar( - r#" + for (migration_version, index_name, table_name) in [ + ( + 20260715000000_i64, + "idx_usage_legacy_body_ref_cleanup_created_at", + "public.usage", + ), + ( + 20260918000000, + "idx_usage_settlement_dashboard_cover_v2", + "public.usage_settlement_snapshots", + ), + ( + 20260920000000, + "idx_payment_orders_status_credited_user", + "public.payment_orders", + ), + ( + 20260921020100, + "ix_usage_analytics_actor_metadata", + "public.usage", + ), + ] { + query(&format!("DROP INDEX CONCURRENTLY public.{index_name}")) + .execute(&pool) + .await + .expect("migrated index should exist"); + query(&format!("CREATE UNIQUE INDEX CONCURRENTLY {index_name} ON public.concurrent_index_failure_fixture (value)")) + .execute(&pool) + .await + .expect_err("duplicate values should leave a failed concurrent index build"); + let invalid_index_exists: bool = query_scalar( + r#" SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class AS index_relation - JOIN pg_catalog.pg_namespace AS index_namespace - ON index_namespace.oid = index_relation.relnamespace - JOIN pg_catalog.pg_index AS index_state - ON index_state.indexrelid = index_relation.oid - WHERE index_namespace.nspname = 'public' - AND index_relation.relname = 'idx_usage_legacy_body_ref_cleanup_created_at' - AND NOT index_state.indisvalid -) -"#, + SELECT 1 FROM pg_catalog.pg_index + WHERE indexrelid = to_regclass($1) AND NOT indisvalid +)"#, + ) + .bind(format!("public.{index_name}")) + .fetch_one(&pool) + .await + .expect("failed concurrent index state should be readable"); + assert!(invalid_index_exists); + + query("DELETE FROM public._sqlx_migrations WHERE version = $1") + .bind(migration_version) + .execute(&pool) + .await + .expect("index migration stamp should be reset"); + super::run_migrations(&pool) + .await + .expect("migration retry should replace the invalid index"); + let valid_index_exists: bool = query_scalar( + r#" +SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_index + WHERE indexrelid = to_regclass($1) AND indrelid = $2::regclass AND indisvalid +)"#, + ) + .bind(format!("public.{index_name}")) + .bind(table_name) + .fetch_one(&pool) + .await + .expect("rebuilt index state should be readable"); + assert!(valid_index_exists); + } + let index_definition: String = query_scalar( + "SELECT pg_get_indexdef('public.idx_usage_settlement_dashboard_cover_v2'::regclass)", ) .fetch_one(&pool) .await - .expect("failed concurrent index state should be readable"); - assert!(invalid_index_exists); - - query("DELETE FROM public._sqlx_migrations WHERE version = $1") - .bind(MIGRATION_VERSION) - .execute(&pool) - .await - .expect("cleanup index migration stamp should be reset"); - super::run_migrations(&pool) - .await - .expect("migration retry should replace the invalid index"); - - let valid_usage_index_exists: bool = query_scalar( - r#" -SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class AS index_relation - JOIN pg_catalog.pg_namespace AS index_namespace - ON index_namespace.oid = index_relation.relnamespace - JOIN pg_catalog.pg_index AS index_state - ON index_state.indexrelid = index_relation.oid - WHERE index_namespace.nspname = 'public' - AND index_relation.relname = 'idx_usage_legacy_body_ref_cleanup_created_at' - AND index_state.indrelid = 'public.usage'::regclass - AND index_state.indisvalid -) -"#, + .expect("replacement index should be installed"); + assert!(index_definition.contains("billing_status")); + assert!(index_definition.contains("allocation_status")); + let old_cover_exists: bool = query_scalar( + "SELECT to_regclass('public.idx_usage_settlement_dashboard_cover') IS NOT NULL", ) .fetch_one(&pool) .await - .expect("rebuilt cleanup index state should be readable"); - assert!(valid_usage_index_exists); + .expect("old covering index state should be readable"); + assert!( + !old_cover_exists, + "successful migration must retire the redundant old cover" + ); } #[tokio::test] diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_user_anonymization.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_user_anonymization.rs new file mode 100644 index 000000000..df1c2833c --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/dashboard_user_anonymization.rs @@ -0,0 +1,231 @@ +use super::*; +use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery; + +const ANONYMIZATION_VERSION: i64 = 20261001000000; +const INSERT_USER: &str = "INSERT INTO users(id,username,email_verified) VALUES($1,$1,false)"; +const INSERT_USAGE: &str = "INSERT INTO usage(id,request_id,user_id,model,provider_name,status,billing_status,created_at) VALUES($1,$1,$2,'anonymization','test','completed','settled',clock_timestamp())"; + +async fn assert_anonymous(pool: &PgPool, user: &str) { + let counts: (i64, i64) = sqlx::query_as( + "SELECT (SELECT count(*) FROM dashboard_actor_minute WHERE actor_user_id=$1), (SELECT count(*) FROM dashboard_request_contributions WHERE actor_user_id=$1)", + ) + .bind(user) + .fetch_one(pool) + .await + .unwrap(); + assert_eq!(counts, (0, 0), "retained identity for {user}"); +} + +#[tokio::test] +async fn dashboard_user_anonymization_preserves_totals_without_migration_backfill() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + connection.ensure_migrations_table().await.unwrap(); + for migration in POSTGRES_MIGRATOR + .iter() + .filter(|migration| migration.version < ANONYMIZATION_VERSION) + { + connection.apply(migration).await.unwrap(); + } + let pool = PgPool::connect(server.database_url()).await.unwrap(); + query(INSERT_USER) + .bind("legacy-deleted") + .execute(&pool) + .await + .unwrap(); + query(INSERT_USAGE) + .bind("legacy-request") + .bind("legacy-deleted") + .execute(&pool) + .await + .unwrap(); + query("DELETE FROM usage WHERE request_id='legacy-request'") + .execute(&pool) + .await + .unwrap(); + query("DELETE FROM users WHERE id='legacy-deleted'") + .execute(&pool) + .await + .unwrap(); + let actor_before: String = + query_scalar("SELECT jsonb_agg(to_jsonb(a))::text FROM dashboard_actor_minute a") + .fetch_one(&pool) + .await + .unwrap(); + + // An upgrade must succeed even while historical projection tables are + // inaccessible: install definitions without reading or rewriting their rows. + let mut blocked_history = pool.begin().await.unwrap(); + query("LOCK TABLE dashboard_actor_minute, dashboard_request_contributions IN ACCESS EXCLUSIVE MODE") + .execute(&mut *blocked_history).await.unwrap(); + query("SET lock_timeout='500ms'") + .execute(&mut connection) + .await + .unwrap(); + let migration = POSTGRES_MIGRATOR + .iter() + .find(|migration| migration.version == ANONYMIZATION_VERSION) + .unwrap(); + connection.apply(migration).await.unwrap(); + blocked_history.rollback().await.unwrap(); + let actor_after: String = + query_scalar("SELECT jsonb_agg(to_jsonb(a))::text FROM dashboard_actor_minute a") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + actor_before, actor_after, + "migration must not rewrite old actors" + ); + + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + let summary_query = UsageDashboardAnalyticsQuery { + timezone: "UTC".into(), + }; + let legacy = repo.query_dashboard_summary(&summary_query).await.unwrap(); + assert_eq!( + legacy.today.active_users, 0, + "old orphan actors must be excluded without backfill" + ); + assert_eq!(legacy.total.request_count, 1); + + for (user, purge, soft) in [ + ("hard-live", false, false), + ("hard-purged", true, false), + ("soft-live", false, true), + ("soft-purged", true, true), + ] { + query(INSERT_USER).bind(user).execute(&pool).await.unwrap(); + query(INSERT_USAGE) + .bind(user) + .bind(user) + .execute(&pool) + .await + .unwrap(); + if purge { + query("DELETE FROM usage WHERE request_id=$1") + .bind(user) + .execute(&pool) + .await + .unwrap(); + } + let before = repo.query_dashboard_summary(&summary_query).await.unwrap(); + let sql = if soft { + "UPDATE users SET is_deleted=true WHERE id=$1" + } else { + "DELETE FROM users WHERE id=$1" + }; + query(sql).bind(user).execute(&pool).await.unwrap(); + assert_anonymous(&pool, user).await; + let after = repo.query_dashboard_summary(&summary_query).await.unwrap(); + assert_eq!( + after.total, before.total, + "deletion must preserve request totals" + ); + assert_eq!(after.today.active_users, 0); + if !purge { + query("UPDATE usage SET response_time_ms=200 WHERE request_id=$1") + .bind(user) + .execute(&pool) + .await + .unwrap(); + assert_anonymous(&pool, user).await; + } + if soft { + query("DELETE FROM users WHERE id=$1") + .bind(user) + .execute(&pool) + .await + .unwrap(); + assert_anonymous(&pool, user).await; + } + } + + // Exercise both deferred-event orders, plus explicitly immediate constraint + // triggers. A captured deleted_fact must never recreate a deleted actor. + for (user, user_first, immediate) in [ + ("deferred-request-first", false, false), + ("deferred-user-first", true, false), + ("immediate-user", true, true), + ] { + query(INSERT_USER).bind(user).execute(&pool).await.unwrap(); + let mut tx = pool.begin().await.unwrap(); + if immediate { + query("SET CONSTRAINTS ALL IMMEDIATE") + .execute(&mut *tx) + .await + .unwrap(); + } + query(INSERT_USAGE) + .bind(user) + .bind(user) + .execute(&mut *tx) + .await + .unwrap(); + if user_first { + query("DELETE FROM users WHERE id=$1") + .bind(user) + .execute(&mut *tx) + .await + .unwrap(); + } + query("DELETE FROM usage WHERE request_id=$1") + .bind(user) + .execute(&mut *tx) + .await + .unwrap(); + if !user_first { + query("DELETE FROM users WHERE id=$1") + .bind(user) + .execute(&mut *tx) + .await + .unwrap(); + } + tx.commit().await.unwrap(); + assert_anonymous(&pool, user).await; + } + + // A concurrent writer can still see a user after its deletion statement + // but before that transaction commits. Check both commit orders: the new + // actor must be rejected after the shard lock, or removed by the deleter. + for (user, writer_first) in [ + ("concurrent-delete-first", false), + ("concurrent-writer-first", true), + ] { + query(INSERT_USER).bind(user).execute(&pool).await.unwrap(); + let mut deletion = pool.begin().await.unwrap(); + query("DELETE FROM users WHERE id=$1") + .bind(user) + .execute(&mut *deletion) + .await + .unwrap(); + let mut writer = pool.begin().await.unwrap(); + query(INSERT_USAGE) + .bind(user) + .bind(user) + .execute(&mut *writer) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + if writer_first { + writer.commit().await.unwrap(); + deletion.commit().await.unwrap(); + } else { + deletion.commit().await.unwrap(); + writer.commit().await.unwrap(); + } + }) + .await + .expect("concurrent deletion and usage must not deadlock"); + assert_anonymous(&pool, user).await; + } + let final_summary = repo.query_dashboard_summary(&summary_query).await.unwrap(); + assert_eq!(final_summary.total.request_count, 10); + assert_eq!(final_summary.today.active_users, 0); + let invalid: (i64, i64, i64) = sqlx::query_as("SELECT (SELECT count(*) FROM dashboard_actor_minute WHERE request_count < 0), (SELECT count(*) FROM dashboard_stats_pending), (SELECT count(*) FROM dashboard_user_anonymization_pending)") + .fetch_one(&pool).await.unwrap(); + assert_eq!(invalid, (0, 0, 0)); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs new file mode 100644 index 000000000..12e879f91 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/legacy_overview_upgrade.rs @@ -0,0 +1,211 @@ +use super::*; + +const ACCOUNT_ATTRIBUTION: i64 = 20260917000000; +const DIRTY_EVENTS: i64 = 20260917000100; + +async fn rows_snapshot(pool: &PgPool, table: &str) -> String { + query_scalar(&format!( + "SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY to_jsonb(t)::text), '[]')::text FROM {table} t" + )) + .fetch_one(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn legacy_overview_upgrade_preserves_applied_history_and_existing_statistics() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + connection.ensure_migrations_table().await.unwrap(); + for migration in POSTGRES_MIGRATOR.iter().filter(|migration| { + migration.version <= 20260920120000 && migration.version != DIRTY_EVENTS + }) { + connection.apply(migration).await.unwrap(); + } + connection.close().await.unwrap(); + let pool = PgPool::connect(server.database_url()).await.unwrap(); + + // The restored September 17 migration installs the historical direct-write + // trigger. Remove schema additions folded into the September 11 baseline + // so this exercises an already-running database that never received them. + sqlx::raw_sql( + r#" +DROP TABLE public.stats_overview_dirty_events; +DROP INDEX public.ix_usage_attribution_owner_request; +UPDATE public.dashboard_stats_state SET stats_since = clock_timestamp() - INTERVAL '1 day'; +INSERT INTO users(id, username, email_verified) +VALUES ('legacy-upgrade-owner', 'legacy-upgrade-owner', false); +INSERT INTO api_keys(id, user_id, key_hash) +VALUES ('legacy-upgrade-key', 'legacy-upgrade-owner', repeat('a', 64)); +INSERT INTO usage(id, request_id, user_id, api_key_id, provider_name, model, + status, billing_status, created_at, response_time_ms) +VALUES ('legacy-upgrade-request', 'legacy-upgrade-request', + 'legacy-upgrade-owner', 'legacy-upgrade-key', 'test', 'test', + 'completed', 'settled', clock_timestamp() - INTERVAL '1 minute', 100); +INSERT INTO stats_overview_hourly(projection_version, bucket_start, dimensions, metrics) +SELECT projection_version, bucket_start, '{}'::jsonb, '{"request_count":1}'::jsonb +FROM stats_bucket_state WHERE granularity = 'hour'; +INSERT INTO stats_overview_daily(projection_version, bucket_start, dimensions, metrics) +SELECT projection_version, bucket_start, '{}'::jsonb, '{"request_count":1}'::jsonb +FROM stats_bucket_state WHERE granularity = 'day'; +UPDATE stats_bucket_state SET built_revision=source_revision, coverage_status='complete'; +"#, + ) + .execute(&pool) + .await + .unwrap(); + + // Model the old September 19 schema after creating real dashboard totals. + // No fact is changed while its later retention helpers are absent. + sqlx::raw_sql( + r#" +DROP FUNCTION public.dashboard_ensure_activity_minute(timestamptz, smallint); +DROP TABLE public.dashboard_activity_minute; +ALTER TABLE public.dashboard_stats_state DROP COLUMN contributions_cleanup_cursor; +UPDATE _sqlx_migrations SET checksum=decode( + '1dd622827b22ae0540f5e43232e7419727aa02d0742649af7e045185e9a13f68655ef7b31007bd0bf6e9e63177022815', 'hex') +WHERE version=20260911000000; +UPDATE _sqlx_migrations SET checksum=decode( + 'c64293c5d95fba6c3c43fff764a89da0225b386cfbef14743ab585e1db012fea35fe2cb2eee132e0fb5dac834c0410f4', 'hex') +WHERE version=20260919000000; +"#, + ) + .execute(&pool) + .await + .unwrap(); + + let historical_records = rows_snapshot(&pool, "_sqlx_migrations").await; + let preserved_tables = [ + "users", + "api_keys", + "usage", + "usage_attribution_snapshots", + "stats_bucket_state", + "stats_overview_hourly", + "stats_overview_daily", + "dashboard_request_contributions", + "dashboard_stats_total", + "dashboard_stats_minute", + "dashboard_activity_hour", + "dashboard_actor_minute", + ]; + let mut original_rows = Vec::new(); + for table in preserved_tables { + original_rows.push((table, rows_snapshot(&pool, table).await)); + } + let account_record: String = + query_scalar("SELECT to_jsonb(m)::text FROM _sqlx_migrations m WHERE version=$1") + .bind(ACCOUNT_ATTRIBUTION) + .fetch_one(&pool) + .await + .unwrap(); + let pending = prepare_database_for_startup(&pool).await.unwrap(); + assert_eq!( + pending + .iter() + .map(|migration| migration.version) + .collect::>(), + vec![DIRTY_EVENTS, 20260921010000, 20260921020000, 20260921020100, 20261001000000] + ); + assert_eq!( + rows_snapshot(&pool, "_sqlx_migrations").await, + historical_records + ); + + for _ in 0..2 { + super::super::run_migrations(&pool).await.unwrap(); + assert!(super::super::pending_migrations(&pool) + .await + .unwrap() + .is_empty()); + for (table, expected) in &original_rows { + assert_eq!(&rows_snapshot(&pool, table).await, expected, "{table}"); + } + let after: String = + query_scalar("SELECT to_jsonb(m)::text FROM _sqlx_migrations m WHERE version=$1") + .bind(ACCOUNT_ATTRIBUTION) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + after, account_record, + "the historical migration must not be restamped" + ); + } + assert!(table_exists(&pool, "stats_overview_dirty_events") + .await + .unwrap()); + assert!(table_exists(&pool, "dashboard_activity_minute") + .await + .unwrap()); + assert!(column_exists( + &pool, + "dashboard_stats_state", + "contributions_cleanup_cursor" + ) + .await + .unwrap()); + for index in [ + "ix_usage_attribution_owner_request", + "ix_usage_analytics_actor_metadata", + ] { + let valid: bool = + query_scalar("SELECT indisvalid FROM pg_index WHERE indexrelid=to_regclass($1)") + .bind(index) + .fetch_one(&pool) + .await + .unwrap(); + assert!(valid, "{index}"); + } + let empty_projections: (i64, i64) = sqlx::query_as( + "SELECT (SELECT count(*) FROM stats_overview_dirty_events), (SELECT count(*) FROM dashboard_activity_minute)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(empty_projections, (0, 0), "upgrade must not backfill usage"); + + // Correcting an existing request must seed the retained activity counter + // from its old detailed minute, without counting that request twice. + query("UPDATE usage SET response_time_ms=200 WHERE request_id='legacy-upgrade-request'") + .execute(&pool) + .await + .unwrap(); + let corrected: (i64, i64, i64) = sqlx::query_as( + "SELECT (SELECT sum((metrics->>'request_count')::bigint)::bigint FROM dashboard_stats_total), (SELECT sum(request_count)::bigint FROM dashboard_activity_minute), (SELECT sum((metrics->>'response_sum_ms')::bigint)::bigint FROM dashboard_stats_total)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(corrected, (1, 1, 200)); + query( + "INSERT INTO usage(id,request_id,user_id,api_key_id,provider_name,model,status,billing_status,created_at,response_time_ms) SELECT 'after-upgrade','after-upgrade',user_id,api_key_id,provider_name,model,status,billing_status,created_at,300 FROM usage WHERE request_id='legacy-upgrade-request'", + ) + .execute(&pool) + .await + .unwrap(); + let written: (i64, i64, i64) = sqlx::query_as( + "SELECT (SELECT sum((metrics->>'request_count')::bigint)::bigint FROM dashboard_stats_total), (SELECT sum(request_count)::bigint FROM dashboard_activity_minute), (SELECT count(*) FROM stats_overview_dirty_events)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(written, (2, 2, 4)); + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + assert_eq!( + repo.rebuild_overview_buckets(chrono::Utc::now() + chrono::Duration::days(1), 8) + .await + .unwrap(), + 2 + ); + let rebuilt: (i64, i64) = sqlx::query_as( + "SELECT (SELECT sum((metrics->>'request_count')::bigint)::bigint FROM stats_overview_hourly), (SELECT count(*) FROM stats_overview_dirty_events)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rebuilt, (2, 0)); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/migration_deadlines.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/migration_deadlines.rs new file mode 100644 index 000000000..894caa734 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/migration_deadlines.rs @@ -0,0 +1,318 @@ +use super::*; +use sqlx::postgres::PgPoolOptions; +use std::time::{Duration, Instant}; + +const OVERVIEW_MIGRATION: i64 = 20260911000000; + +async fn legacy_connection(server: &ManagedPostgresServer) -> PgConnection { + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + connection.ensure_migrations_table().await.unwrap(); + for migration in POSTGRES_MIGRATOR + .iter() + .filter(|migration| migration.version < OVERVIEW_MIGRATION) + { + connection.apply(migration).await.unwrap(); + } + connection +} + +async fn wait_for_settlement_ddl(connection: &mut PgConnection) { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let waiting: bool = query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='public.usage_settlement_snapshots'::regclass AND mode='AccessExclusiveLock' AND NOT granted)", + ) + .fetch_one(&mut *connection) + .await + .unwrap(); + if waiting { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("migration should reach the blocked settlement ALTER TABLE"); +} + +async fn assert_overview_migration_rolled_back(pool: &PgPool) { + assert!( + !column_exists(pool, "usage", "failure_origin") + .await + .unwrap(), + "the earlier ALTER TABLE must roll back with the blocked statement" + ); + let stamped: bool = + query_scalar("SELECT EXISTS (SELECT 1 FROM public._sqlx_migrations WHERE version=$1)") + .bind(OVERVIEW_MIGRATION) + .fetch_one(pool) + .await + .unwrap(); + assert!( + !stamped, + "a failed migration must not receive a success stamp" + ); +} + +#[tokio::test] +async fn migration_deadlines_release_queued_usage_work_and_roll_back_before_retry() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut observer = legacy_connection(&server).await; + let pool = PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + query("SET statement_timeout='30s'") + .execute(&mut *connection) + .await?; + query("SET lock_timeout='3s'") + .execute(&mut *connection) + .await?; + Ok(()) + }) + }) + .connect(server.database_url()) + .await + .unwrap(); + let original_pid: i32 = query_scalar("SELECT pg_backend_pid()") + .fetch_one(&pool) + .await + .unwrap(); + let mut business = PgConnection::connect(server.database_url()).await.unwrap(); + let mut business_transaction = business.begin().await.unwrap(); + query("LOCK TABLE public.usage_settlement_snapshots IN ACCESS SHARE MODE") + .execute(&mut *business_transaction) + .await + .unwrap(); + let mut queued_business = PgConnection::connect(server.database_url()).await.unwrap(); + + // The usage ALTERs take their table exclusively, then settlement ALTER + // waits behind an existing reader. Business writes wait on the usage lock + // already held by this transaction and must resume when it rolls back. + let started = Instant::now(); + let (migration_result, ()) = tokio::join!(super::super::run_migrations(&pool), async { + wait_for_settlement_ddl(&mut observer).await; + let acquired_first_lock: bool = query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks WHERE relation='public.usage'::regclass AND mode='AccessExclusiveLock' AND granted)", + ) + .fetch_one(&mut observer) + .await + .unwrap(); + assert!( + acquired_first_lock, + "the migration must have already changed the first table" + ); + let queued_write = query("INSERT INTO public.usage(id,request_id,model,provider_name,status,billing_status,created_at) VALUES ('migration-live-request','migration-live-request','test','test','completed','settled',NOW())") + .execute(&mut queued_business); + tokio::pin!(queued_write); + tokio::select! { + result = &mut queued_write => panic!("the business write should initially queue behind the DDL: {result:?}"), + () = tokio::time::sleep(Duration::from_millis(100)) => {} + } + let written = tokio::time::timeout(Duration::from_secs(4), queued_write) + .await + .expect("queued business work must resume after the migration's lock timeout") + .unwrap(); + assert_eq!(written.rows_affected(), 1); + }); + let error = + migration_result.expect_err("busy settlement table must defer this upgrade attempt"); + assert!( + error.to_string().contains("lock timeout"), + "the failure should identify the bounded lock wait: {error}" + ); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_overview_migration_rolled_back(&pool).await; + + let (new_pid, statement_timeout, lock_timeout): (i32, String, String) = sqlx::query_as( + "SELECT pg_backend_pid(), current_setting('statement_timeout'), current_setting('lock_timeout')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!( + new_pid, original_pid, + "failed migration connection must be discarded" + ); + assert_eq!(statement_timeout, "30s"); + assert_eq!(lock_timeout, "3s"); + + business_transaction.rollback().await.unwrap(); + super::super::run_migrations(&pool).await.unwrap(); + assert!(super::super::pending_migrations(&pool) + .await + .unwrap() + .is_empty()); + let success: bool = + query_scalar("SELECT success FROM public._sqlx_migrations WHERE version=$1") + .bind(OVERVIEW_MIGRATION) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + success, + "a later quiet retry must succeed without manual stamp repair" + ); + assert_eq!( + query_scalar::<_, i64>( + "SELECT count(*) FROM public.usage WHERE request_id='migration-live-request'" + ) + .fetch_one(&pool) + .await + .unwrap(), + 1, + "the resumed business write must survive the upgrade retry" + ); + let settings: (String, String) = sqlx::query_as( + "SELECT current_setting('statement_timeout'), current_setting('lock_timeout')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(settings, ("30s".into(), "3s".into())); + + // Startup preparation also takes SQLx's advisory migration lock. Another + // upgrade process must not leave startup waiting indefinitely for it. + observer.lock().await.unwrap(); + let preparation = + tokio::time::timeout(Duration::from_secs(4), prepare_database_for_startup(&pool)) + .await + .expect("startup preparation must bound its advisory-lock wait"); + let preparation_error = preparation.expect_err("another migration owns the advisory lock"); + assert!(preparation_error.to_string().contains("lock timeout")); + observer.unlock().await.unwrap(); + assert!(prepare_database_for_startup(&pool) + .await + .unwrap() + .is_empty()); + pool.close().await; +} + +#[tokio::test] +async fn migration_deadlines_caller_cancellation_releases_ddl_locks_and_rolls_back() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = legacy_connection(&server).await; + sqlx::raw_sql( + r#" +CREATE FUNCTION public.test_cancelled_migration_ddl() RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN PERFORM pg_sleep(8); END $$; +CREATE EVENT TRIGGER test_cancelled_migration_ddl ON ddl_command_end + WHEN TAG IN ('ALTER TABLE') EXECUTE FUNCTION public.test_cancelled_migration_ddl(); +"#, + ) + .execute(&mut connection) + .await + .unwrap(); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(server.database_url()) + .await + .unwrap(); + // Keep this future in an inner scope: leaving it actually drops the entire + // migration operation, rather than merely dropping a pinned reference. + { + let migrate = super::super::run_migrations(&pool); + tokio::pin!(migrate); + tokio::select! { + result = &mut migrate => panic!("the caller must cancel before the slow migration finishes: {result:?}"), + () = async { + tokio::time::timeout(Duration::from_secs(4), async { + loop { + let running: bool = query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks l JOIN pg_stat_activity a USING(pid) WHERE l.relation='public.usage'::regclass AND l.mode='AccessExclusiveLock' AND l.granted AND a.wait_event='PgSleep')", + ) + .fetch_one(&mut connection) + .await + .unwrap(); + if running { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("migration must acquire its first DDL lock before caller cancellation"); + } => {} + } + } + tokio::time::timeout( + Duration::from_secs(4), + query_scalar::<_, i64>("SELECT count(*) FROM public.usage").fetch_one(&pool), + ) + .await + .expect("dropping the caller future must stop the server-side statement and release DDL locks") + .unwrap(); + assert_overview_migration_rolled_back(&pool).await; + sqlx::raw_sql( + "DROP EVENT TRIGGER test_cancelled_migration_ddl; DROP FUNCTION public.test_cancelled_migration_ddl()", + ) + .execute(&mut connection) + .await + .unwrap(); + super::super::run_migrations(&pool).await.unwrap(); + assert!(super::super::pending_migrations(&pool) + .await + .unwrap() + .is_empty()); + pool.close().await; +} + +#[tokio::test] +async fn migration_deadlines_bound_the_whole_transaction_not_only_each_statement() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = legacy_connection(&server).await; + // Each ALTER finishes within the ten-second statement limit, but the + // entire migration exceeds it. A per-statement timeout alone is insufficient. + sqlx::raw_sql( + r#" +CREATE FUNCTION public.test_slow_migration_ddl() RETURNS event_trigger LANGUAGE plpgsql AS $$ +BEGIN PERFORM pg_sleep(3); END $$; +CREATE EVENT TRIGGER test_slow_migration_ddl ON ddl_command_end + WHEN TAG IN ('ALTER TABLE') EXECUTE FUNCTION public.test_slow_migration_ddl(); +"#, + ) + .execute(&mut connection) + .await + .unwrap(); + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(server.database_url()) + .await + .unwrap(); + let started = Instant::now(); + let result = tokio::time::timeout(Duration::from_secs(15), super::super::run_migrations(&pool)) + .await + .expect("the whole migration deadline must fire before all slow statements finish"); + assert!(result.is_err(), "the over-budget migration must fail"); + assert!( + started.elapsed() >= Duration::from_secs(9), + "upgrade failed before its deadline: {result:?}" + ); + assert!(started.elapsed() < Duration::from_secs(15)); + // The backend can still be unwinding the statement when its socket closes. + // Reading the first locked table proves cancellation released the DDL lock. + tokio::time::timeout( + Duration::from_secs(4), + query_scalar::<_, i64>("SELECT count(*) FROM public.usage").fetch_one(&pool), + ) + .await + .expect("DDL locks must be released when the migration connection closes") + .unwrap(); + assert_overview_migration_rolled_back(&pool).await; + sqlx::raw_sql( + "DROP EVENT TRIGGER test_slow_migration_ddl; DROP FUNCTION public.test_slow_migration_ddl()", + ) + .execute(&mut connection) + .await + .unwrap(); + super::super::run_migrations(&pool).await.unwrap(); + assert!(super::super::pending_migrations(&pool) + .await + .unwrap() + .is_empty()); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/overview_dirty_events.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/overview_dirty_events.rs new file mode 100644 index 000000000..99de0a817 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/overview_dirty_events.rs @@ -0,0 +1,154 @@ +use super::*; +use aether_data_contracts::repository::usage::{UsageAnalyticsQuery, UsageAnalyticsView}; +use chrono::{TimeZone, Utc}; + +#[tokio::test] +async fn overview_dirty_events_keep_independent_usage_writes_concurrent_and_reads_fresh() { + let Some(server) = ManagedPostgresServer::try_start() + .await + .expect("overview PostgreSQL should start or skip") + else { + return; + }; + let pool = PgPool::connect(server.database_url()).await.unwrap(); + prepare_and_apply_clean_postgres_database(&pool).await; + let at = Utc.with_ymd_and_hms(2026, 1, 2, 3, 0, 0).unwrap(); + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + let insert = "INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,created_at,response_time_ms) VALUES($1,$1,'dirty-event-test','test','completed','settled',$2,100)"; + let second_id: String = query_scalar( + "SELECT 'second-' || n FROM generate_series(1,100) n WHERE (hashtextextended('second-' || n,0) & 15) <> (hashtextextended('first',0) & 15) LIMIT 1", + ) + .fetch_one(&pool) + .await + .unwrap(); + + // A stays open while B writes the same hour/day. Before the migration, + // B times out on the shared stats_bucket_state day row despite a distinct request. + let mut first = pool.begin().await.unwrap(); + sqlx::query(insert) + .bind("first") + .bind(at) + .execute(&mut *first) + .await + .unwrap(); + let mut second = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL lock_timeout='500ms'") + .execute(&mut *second) + .await + .unwrap(); + sqlx::query(insert) + .bind(&second_id) + .bind(at) + .execute(&mut *second) + .await + .unwrap(); + second.commit().await.unwrap(); + assert_eq!( + repo.rebuild_overview_buckets(at + chrono::Duration::days(1), 8) + .await + .unwrap(), + 2 + ); + let query = UsageAnalyticsQuery { + from_unix_ms: at.timestamp_millis() as u64, + to_unix_ms: (at + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), + view: UsageAnalyticsView::Summary, + limit: 100, + ..Default::default() + }; + assert_eq!( + repo.query_usage_analytics(&query) + .await + .unwrap() + .summary + .request_count, + 1 + ); + + // Commit order differs from transaction-ID order. Consuming B must never + // acknowledge A, and A must invalidate the already-published clean projection. + first.commit().await.unwrap(); + let pending = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(pending.summary.request_count, 2); + assert_eq!(pending.coverage.dirty_bucket_count, 1); + let queued: i64 = query_scalar("SELECT count(*) FROM stats_overview_dirty_events") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(queued, 2); + assert_eq!( + repo.rebuild_overview_buckets(at + chrono::Duration::days(1), 8) + .await + .unwrap(), + 2 + ); + let rebuilt = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(rebuilt.summary, pending.summary); + assert_eq!(rebuilt.coverage.dirty_bucket_count, 0); + assert_eq!( + query_scalar::<_, i64>("SELECT count(*) FROM stats_overview_dirty_events") + .fetch_one(&pool) + .await + .unwrap(), + 0 + ); + + let mut rolled_back = pool.begin().await.unwrap(); + sqlx::query(insert) + .bind("rollback") + .bind(at) + .execute(&mut *rolled_back) + .await + .unwrap(); + rolled_back.rollback().await.unwrap(); + assert_eq!( + query_scalar::<_, i64>("SELECT count(*) FROM stats_overview_dirty_events") + .fetch_one(&pool) + .await + .unwrap(), + 0 + ); + + // Repeated mutations in one transaction deduplicate, but deleting facts + // retains the irreversible-loss marker until it is merged into state. + let mut deleted = pool.begin().await.unwrap(); + sqlx::query("UPDATE usage SET response_time_ms=200 WHERE request_id='first'") + .execute(&mut *deleted) + .await + .unwrap(); + sqlx::query("DELETE FROM usage WHERE request_id='first'") + .execute(&mut *deleted) + .await + .unwrap(); + deleted.commit().await.unwrap(); + assert_eq!( + query_scalar::<_, i64>( + "SELECT count(*) FROM stats_overview_dirty_events WHERE unrecoverable" + ) + .fetch_one(&pool) + .await + .unwrap(), + 2 + ); + let lost = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(lost.summary.request_count, 1); + assert_eq!(lost.unrecoverable_bucket_count, 1); + assert_eq!( + repo.rebuild_overview_buckets(at + chrono::Duration::days(1), 8) + .await + .unwrap(), + 1 + ); + let merged = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(merged.unrecoverable_bucket_count, 1); + assert_eq!(merged.summary, lost.summary); + assert_eq!( + query_scalar::<_, i64>("SELECT count(*) FROM stats_overview_dirty_events") + .fetch_one(&pool) + .await + .unwrap(), + 0 + ); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/overview_migration_safety.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/overview_migration_safety.rs new file mode 100644 index 000000000..adef65b6f --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/overview_migration_safety.rs @@ -0,0 +1,163 @@ +use super::*; +use aether_data_contracts::repository::usage::{UsageAnalyticsQuery, UsageAnalyticsView}; +use chrono::{TimeZone, Utc}; + +const OVERVIEW_START: i64 = 20260911000000; +const BILLING_INDEX: i64 = 20260918000000; +const INSERT_USAGE: &str = "INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,created_at) VALUES($1,$1,'overview-migration-test','test','completed','settled',$2)"; + +async fn apply_through_overview(connection: &mut PgConnection) { + connection.ensure_migrations_table().await.unwrap(); + for migration in POSTGRES_MIGRATOR + .iter() + .filter(|migration| migration.version <= OVERVIEW_START) + { + connection.apply(migration).await.unwrap(); + } +} + +async fn assert_independent_same_bucket_writes(pool: &PgPool, prefix: &str) { + let at = Utc.with_ymd_and_hms(2026, 1, 2, 3, 0, 0).unwrap(); + let mut first = pool.begin().await.unwrap(); + query(INSERT_USAGE) + .bind(format!("{prefix}-first")) + .bind(at) + .execute(&mut *first) + .await + .unwrap(); + let mut second = pool.begin().await.unwrap(); + query("SET LOCAL lock_timeout='500ms'") + .execute(&mut *second) + .await + .unwrap(); + query(INSERT_USAGE) + .bind(format!("{prefix}-second")) + .bind(at) + .execute(&mut *second) + .await + .expect("another request in the same hour/day must not wait for the first transaction"); + second.commit().await.unwrap(); + first.commit().await.unwrap(); +} + +async fn is_stamped(pool: &PgPool, version: i64) -> bool { + query_scalar("SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version=$1 AND success)") + .bind(version) + .fetch_one(pool) + .await + .unwrap() +} + +async fn queue_and_state(pool: &PgPool) -> (String, String) { + sqlx::query_as( + r#" +SELECT + (SELECT COALESCE(jsonb_agg(to_jsonb(e) ORDER BY transaction_id,projection_version,granularity,bucket_start),'[]')::text FROM stats_overview_dirty_events e), + (SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY projection_version,granularity,bucket_start),'[]')::text FROM stats_bucket_state s) +"#, + ) + .fetch_one(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn overview_queue_is_safe_from_its_first_installation() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + apply_through_overview(&mut connection).await; + let pool = PgPool::connect(server.database_url()).await.unwrap(); + + // The initial installation must be safe before any subsequent migration, + // including the potentially long concurrent index build, has completed. + assert_independent_same_bucket_writes(&pool, "first-install").await; + let (queued, obsolete, direct): (i64, i64, i64) = sqlx::query_as( + "SELECT (SELECT count(*) FROM stats_overview_dirty_events), (SELECT count(*) FROM stats_overview_dirty_events WHERE projection_version <> 'overview-v2'), (SELECT count(*) FROM stats_bucket_state)", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!((queued, obsolete, direct), (4, 0, 0)); + assert!(!is_stamped(&pool, BILLING_INDEX).await); + pool.close().await; +} + +#[tokio::test] +async fn overview_queue_survives_later_index_failure_and_retry_without_losing_data() { + let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + apply_through_overview(&mut connection).await; + let pool = PgPool::connect(server.database_url()).await.unwrap(); + let at = Utc.with_ymd_and_hms(2026, 1, 2, 3, 0, 0).unwrap(); + query(INSERT_USAGE) + .bind("before-index-failure") + .bind(at) + .execute(&pool) + .await + .unwrap(); + let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone()); + assert_eq!( + repo.rebuild_overview_buckets(at + chrono::Duration::days(1), 8) + .await + .unwrap(), + 2 + ); + let original_state = queue_and_state(&pool).await.1; + + let mut blocker = pool.begin().await.unwrap(); + query("LOCK TABLE usage_settlement_snapshots IN SHARE MODE") + .execute(&mut *blocker) + .await + .unwrap(); + let error = super::super::run_migrations(&pool) + .await + .expect_err("the concurrent index must encounter the held relation lock"); + assert!(error.to_string().contains("lock timeout"), "{error}"); + assert!(is_stamped(&pool, OVERVIEW_START).await); + assert!(!is_stamped(&pool, BILLING_INDEX).await); + + // A failed later migration must leave the safe trigger committed and able + // to accept independent business writes until the upgrade can be retried. + assert_independent_same_bucket_writes(&pool, "failed-upgrade").await; + let after_failure = queue_and_state(&pool).await; + assert_eq!(after_failure.1, original_state); + assert_eq!( + query_scalar::<_, i64>("SELECT count(*) FROM stats_overview_dirty_events") + .fetch_one(&pool) + .await + .unwrap(), + 4 + ); + blocker.rollback().await.unwrap(); + super::super::run_migrations(&pool).await.unwrap(); + assert_eq!(queue_and_state(&pool).await, after_failure); + assert!(super::super::pending_migrations(&pool) + .await + .unwrap() + .is_empty()); + + assert_eq!( + repo.rebuild_overview_buckets(at + chrono::Duration::days(1), 8) + .await + .unwrap(), + 2 + ); + let result = repo + .query_usage_analytics(&UsageAnalyticsQuery { + from_unix_ms: at.timestamp_millis() as u64, + to_unix_ms: (at + chrono::Duration::hours(1)).timestamp_millis() as u64, + timezone: "UTC".into(), + view: UsageAnalyticsView::Summary, + limit: 100, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(result.summary.request_count, 3); + assert_eq!(result.coverage.dirty_bucket_count, 0); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests/provider_expenses.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests/provider_expenses.rs new file mode 100644 index 000000000..03627f783 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests/provider_expenses.rs @@ -0,0 +1,115 @@ +use super::*; +use aether_data_contracts::repository::billing::{ + AdminBillingMutationOutcome, BillingReadRepository, ProviderExpenseInput, ProviderExpenseQuery, +}; + +#[tokio::test] +async fn postgres_provider_expense_ledger_migration_preserves_exact_sums_idempotency_and_voids() { + let Some(server) = ManagedPostgresServer::try_start() + .await + .expect("local postgres should start or skip") + else { + return; + }; + let pool = PgPool::connect(server.database_url()).await.unwrap(); + // Exercise only this self-contained migration against an isolated database. + sqlx::raw_sql(include_str!( + "../../../../schema/bootstrap/postgres/210_provider_expenses.sql" + )) + .execute(&pool) + .await + .unwrap(); + let repository = crate::repository::billing::SqlxBillingReadRepository::new(pool.clone()); + let input = ProviderExpenseInput { + client_request_id: uuid::Uuid::new_v4().to_string(), + provider_id: "deleted-later".into(), + provider_name: "Supplier".into(), + kind: "recharge".into(), + amount: "0.10000000".into(), + currency: "USD".into(), + paid_at_unix_ms: 1000, + period_start_unix_ms: None, + period_end_unix_ms: None, + note: None, + external_reference: None, + created_by: Some("admin".into()), + }; + let (a, b) = tokio::join!( + repository.create_provider_expense(&input), + repository.create_provider_expense(&input) + ); + let (AdminBillingMutationOutcome::Applied(a), AdminBillingMutationOutcome::Applied(b)) = + (a.unwrap(), b.unwrap()) + else { + panic!("expected applied") + }; + assert_eq!(a.id, b.id); + let mut subscription = input.clone(); + subscription.client_request_id = uuid::Uuid::new_v4().to_string(); + subscription.amount = "0.20000000".into(); + subscription.kind = "subscription".into(); + let AdminBillingMutationOutcome::Applied(second) = repository + .create_provider_expense(&subscription) + .await + .unwrap() + else { + panic!("expected applied") + }; + let mut cny = input.clone(); + cny.client_request_id = uuid::Uuid::new_v4().to_string(); + cny.currency = "CNY".into(); + cny.amount = "7.00000000".into(); + repository.create_provider_expense(&cny).await.unwrap(); + let query = ProviderExpenseQuery { + from_unix_ms: 0, + to_unix_ms: 2000, + limit: 1, + offset: 1, + }; + let page = repository + .list_provider_expenses(&query) + .await + .unwrap() + .unwrap(); + assert_eq!(page.total, 3); + assert_eq!(page.items.len(), 1); + assert_eq!(page.totals[0].currency, "CNY"); + assert_eq!(page.totals[1].amount, "0.30000000"); + assert_eq!(page.totals[1].subscription_amount, "0.20000000"); + let mut conflict = input.clone(); + conflict.amount = "5.00000000".into(); + assert!(matches!( + repository.create_provider_expense(&conflict).await.unwrap(), + AdminBillingMutationOutcome::Invalid(_) + )); + let AdminBillingMutationOutcome::Applied(voided) = repository + .void_provider_expense(&second.id, Some("operator-1")) + .await + .unwrap() + else { + panic!("expected void") + }; + let AdminBillingMutationOutcome::Applied(again) = repository + .void_provider_expense(&second.id, Some("operator-2")) + .await + .unwrap() + else { + panic!("expected repeat void") + }; + assert_eq!(voided, again); + let page = repository + .list_provider_expenses(&query) + .await + .unwrap() + .unwrap(); + assert_eq!(page.total, 2); + assert_eq!(page.totals[1].amount, "0.10000000"); + let raw_count: i64 = sqlx::query_scalar("SELECT count(*) FROM provider_expenses") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(raw_count, 3); + let invalid=sqlx::query("INSERT INTO provider_expenses(id,client_request_id,provider_id,provider_name,kind,amount,currency,paid_at) VALUES('bad','bad','p','P','recharge',-1,'USD',NOW())").execute(&pool).await; + assert!(invalid.is_err()); + pool.close().await; +} diff --git a/crates/aether-data/runtime/src/repository/announcements/memory.rs b/crates/aether-data/runtime/src/repository/announcements/memory.rs index 51a9f8abc..afd9bcd80 100644 --- a/crates/aether-data/runtime/src/repository/announcements/memory.rs +++ b/crates/aether-data/runtime/src/repository/announcements/memory.rs @@ -8,7 +8,8 @@ use uuid::Uuid; use crate::DataLayerError; use aether_data_contracts::repository::announcements::{ AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository, - CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord, + CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, StoredUserAnnouncement, + StoredUserAnnouncementPage, UpdateAnnouncementRecord, UserAnnouncementListQuery, }; #[derive(Debug, Default)] @@ -105,6 +106,65 @@ impl AnnouncementReadRepository for InMemoryAnnouncementReadRepository { Ok(StoredAnnouncementPage { items, total }) } + async fn list_user_announcements( + &self, + user_id: &str, + query: &UserAnnouncementListQuery, + ) -> Result { + query.validate()?; + let announcements = self + .announcements + .read() + .expect("announcement repository lock"); + let reads = self + .announcement_reads + .read() + .expect("announcement reads repository lock"); + let mut unread_count = 0; + let mut items = announcements + .iter() + .filter(|announcement| { + announcement.is_active + && announcement + .start_time_unix_secs + .is_none_or(|value| value <= query.now_unix_secs) + && announcement + .end_time_unix_secs + .is_none_or(|value| value >= query.now_unix_secs) + }) + .filter_map(|announcement| { + let is_read = reads.contains(&(user_id.to_string(), announcement.id.clone())); + if !is_read { + unread_count += 1; + } + (!query.unread_only || !is_read).then_some((announcement, is_read)) + }) + .collect::>(); + items.sort_by(|(left, _), (right, _)| { + right + .is_pinned + .cmp(&left.is_pinned) + .then_with(|| right.priority.cmp(&left.priority)) + .then_with(|| right.created_at_unix_ms.cmp(&left.created_at_unix_ms)) + .then_with(|| left.id.cmp(&right.id)) + }); + let total = items.len() as u64; + let items = items + .into_iter() + .skip(query.offset) + .take(query.limit) + .map(|(announcement, is_read)| StoredUserAnnouncement { + announcement: announcement.clone(), + is_read, + }) + .collect(); + Ok(StoredUserAnnouncementPage { + items, + total, + unread_count, + }) + } + async fn count_unread_active_announcements( &self, user_id: &str, @@ -294,9 +354,95 @@ mod tests { use super::InMemoryAnnouncementReadRepository; use crate::repository::announcements::{ AnnouncementReadRepository, AnnouncementWriteRepository, CreateAnnouncementRecord, - StoredAnnouncement, UpdateAnnouncementRecord, + StoredAnnouncement, UpdateAnnouncementRecord, UserAnnouncementListQuery, }; + #[tokio::test] + async fn personal_announcement_page_preserves_global_unread_and_visibility() { + let now = 1_800_000_000; + let announcements = [ + ("pinned", true, true, 0, None, None), + ("normal-a", true, false, 20, Some(now), Some(now)), + ("normal-b", true, false, 20, None, None), + ("draft", false, false, 99, None, None), + ("future", true, false, 99, Some(now + 1), None), + ("expired", true, false, 99, None, Some(now - 1)), + ] + .into_iter() + .map(|(id, active, pinned, priority, start, end)| { + StoredAnnouncement::new( + id.into(), + id.into(), + "content".into(), + "info".into(), + priority, + active, + pinned, + false, + None, + None, + start, + end, + now, + now, + ) + .unwrap() + }); + let repository = InMemoryAnnouncementReadRepository::seed_with_reads( + announcements, + [("reader".into(), "pinned".into())], + ); + let mut query = UserAnnouncementListQuery { + unread_only: false, + offset: 0, + limit: 1, + now_unix_secs: now as u64, + }; + let first = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert_eq!((first.total, first.unread_count), (3, 2)); + assert_eq!(first.items[0].announcement.id, "pinned"); + assert!(first.items[0].is_read); + query.unread_only = true; + query.offset = 1; + let second = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert_eq!((second.total, second.unread_count), (2, 2)); + assert_eq!(second.items[0].announcement.id, "normal-b"); + assert!(!second.items[0].is_read); + query.offset = i64::MAX as usize; + let empty = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert!(empty.items.is_empty()); + assert_eq!((empty.total, empty.unread_count), (2, 2)); + let other = repository + .list_user_announcements("other", &query) + .await + .unwrap(); + assert_eq!((other.total, other.unread_count), (3, 3)); + repository + .mark_announcement_as_read("reader", "normal-a", now as u64) + .await + .unwrap(); + query.offset = 0; + let after_read = repository + .list_user_announcements("reader", &query) + .await + .unwrap(); + assert_eq!((after_read.total, after_read.unread_count), (1, 1)); + query.limit = 101; + assert!(repository + .list_user_announcements("reader", &query) + .await + .is_err()); + } + #[tokio::test] async fn reads_seeded_announcements() { let repository = InMemoryAnnouncementReadRepository::seed(vec![StoredAnnouncement::new( diff --git a/crates/aether-data/runtime/src/repository/announcements/mod.rs b/crates/aether-data/runtime/src/repository/announcements/mod.rs index 3f9c18689..d2e8794aa 100644 --- a/crates/aether-data/runtime/src/repository/announcements/mod.rs +++ b/crates/aether-data/runtime/src/repository/announcements/mod.rs @@ -2,7 +2,8 @@ mod memory; pub use aether_data_contracts::repository::announcements::{ AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository, - CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord, + CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, StoredUserAnnouncement, + StoredUserAnnouncementPage, UpdateAnnouncementRecord, UserAnnouncementListQuery, }; #[cfg(feature = "postgres")] pub use aether_data_postgres::SqlxAnnouncementReadRepository; diff --git a/crates/aether-data/runtime/src/repository/auth/memory.rs b/crates/aether-data/runtime/src/repository/auth/memory.rs index a43b63344..cb4775ec5 100644 --- a/crates/aether-data/runtime/src/repository/auth/memory.rs +++ b/crates/aether-data/runtime/src/repository/auth/memory.rs @@ -229,6 +229,24 @@ impl InMemoryAuthApiKeySnapshotRepository { .unwrap_or(0) } + pub fn standalone_flags(&self) -> BTreeMap { + let index = self + .index + .read() + .expect("auth api key snapshot repository lock"); + index + .export_by_api_key_id + .iter() + .map(|(id, record)| (id.clone(), record.is_standalone)) + .chain( + index + .by_api_key_id + .iter() + .map(|(id, snapshot)| (id.clone(), snapshot.api_key_is_standalone)), + ) + .collect() + } + pub fn snapshot_lookup_count(&self, api_key_id: &str) -> usize { self.index .read() @@ -2101,6 +2119,43 @@ mod tests { .is_some()); } + #[tokio::test] + async fn standalone_flags_cover_export_only_keys_and_remove_deleted_keys() { + let mut standalone = sample_snapshot("standalone-key", "user-1"); + standalone.api_key_is_standalone = true; + let repository = InMemoryAuthApiKeySnapshotRepository::seed([ + (None, sample_snapshot("member-key", "user-1")), + (None, standalone), + ]); + let mut export = repository + .list_export_api_keys_by_ids(&["standalone-key".into()]) + .await + .unwrap() + .remove(0); + export.api_key_id = "export-only-key".into(); + let repository = repository.with_export_records([export]); + + assert_eq!( + repository.standalone_flags(), + std::collections::BTreeMap::from([ + ("member-key".into(), false), + ("standalone-key".into(), true), + ("export-only-key".into(), true), + ]) + ); + assert!(repository + .delete_standalone_api_key("standalone-key") + .await + .unwrap()); + assert_eq!( + repository.standalone_flags(), + std::collections::BTreeMap::from([ + ("member-key".into(), false), + ("export-only-key".into(), true), + ]) + ); + } + #[tokio::test] async fn reads_auth_snapshot_by_all_supported_keys() { let repository = InMemoryAuthApiKeySnapshotRepository::seed(vec![( diff --git a/crates/aether-data/runtime/src/repository/billing/memory.rs b/crates/aether-data/runtime/src/repository/billing/memory.rs index 112f7f3fc..c05c50246 100644 --- a/crates/aether-data/runtime/src/repository/billing/memory.rs +++ b/crates/aether-data/runtime/src/repository/billing/memory.rs @@ -16,6 +16,7 @@ type BillingContextMap = BTreeMap; #[derive(Debug, Default)] pub struct InMemoryBillingReadRepository { + provider_expenses: RwLock>, by_key: RwLock, gateway_configs_by_provider: RwLock>, billing_plans_by_id: RwLock>, @@ -23,6 +24,20 @@ pub struct InMemoryBillingReadRepository { } impl InMemoryBillingReadRepository { + pub fn seed_user_plan_entitlements( + items: impl IntoIterator, + ) -> Self { + Self { + entitlements_by_id: RwLock::new( + items + .into_iter() + .map(|item| (item.id.clone(), item)) + .collect(), + ), + ..Self::default() + } + } + pub fn seed(items: I) -> Self where I: IntoIterator, @@ -39,6 +54,7 @@ impl InMemoryBillingReadRepository { ); } Self { + provider_expenses: RwLock::default(), by_key: RwLock::new(by_key), gateway_configs_by_provider: RwLock::new(BTreeMap::new()), billing_plans_by_id: RwLock::new(BTreeMap::new()), @@ -325,6 +341,68 @@ impl BillingReadRepository for InMemoryBillingReadRepository { Ok(AdminBillingMutationOutcome::Applied(record)) } + async fn list_provider_expenses( + &self, + query: &super::ProviderExpenseQuery, + ) -> Result, DataLayerError> { + let guard = self + .provider_expenses + .read() + .expect("provider expense store should lock"); + super::provider_expense_memory_page(guard.values().cloned(), query).map(Some) + } + async fn create_provider_expense( + &self, + input: &super::ProviderExpenseInput, + ) -> Result, DataLayerError> { + if let Err(detail) = input.validate() { + return Ok(AdminBillingMutationOutcome::Invalid(detail)); + } + let mut guard = self + .provider_expenses + .write() + .expect("provider expense store should lock"); + if let Some(existing) = guard + .values() + .find(|r| r.entry.client_request_id == input.client_request_id) + { + return Ok(if existing.entry.same_request_as(input) { + AdminBillingMutationOutcome::Applied(existing.clone()) + } else { + AdminBillingMutationOutcome::Invalid( + "client_request_id was already used for another expense".into(), + ) + }); + } + let record = super::ProviderExpenseRecord { + id: uuid::Uuid::new_v4().to_string(), + entry: input.clone(), + created_at_unix_ms: chrono::Utc::now().timestamp_millis().max(0) as u64, + voided_at_unix_ms: None, + voided_by: None, + }; + guard.insert(record.id.clone(), record.clone()); + Ok(AdminBillingMutationOutcome::Applied(record)) + } + async fn void_provider_expense( + &self, + id: &str, + operator: Option<&str>, + ) -> Result, DataLayerError> { + let mut guard = self + .provider_expenses + .write() + .expect("provider expense store should lock"); + let Some(record) = guard.get_mut(id) else { + return Ok(AdminBillingMutationOutcome::NotFound); + }; + if record.voided_at_unix_ms.is_none() { + record.voided_at_unix_ms = Some(chrono::Utc::now().timestamp_millis().max(0) as u64); + record.voided_by = operator.map(str::to_owned); + } + Ok(AdminBillingMutationOutcome::Applied(record.clone())) + } + async fn list_billing_plans( &self, include_disabled: bool, @@ -435,6 +513,15 @@ impl BillingReadRepository for InMemoryBillingReadRepository { async fn list_user_plan_entitlements( &self, user_id: &str, + ) -> Result>, DataLayerError> { + self.list_user_plan_entitlements_with_history(user_id, false) + .await + } + + async fn list_user_plan_entitlements_with_history( + &self, + user_id: &str, + include_inactive: bool, ) -> Result>, DataLayerError> { let now = current_unix_secs(); let mut items = self @@ -444,12 +531,12 @@ impl BillingReadRepository for InMemoryBillingReadRepository { .values() .filter(|item| { item.user_id == user_id - && item.status == "active" - && item.expires_at_unix_secs > now + && (include_inactive + || (item.status == "active" && item.expires_at_unix_secs > now)) }) .cloned() .collect::>(); - items.sort_by_key(|item| item.expires_at_unix_secs); + items.sort_by_key(|item| (item.expires_at_unix_secs, item.created_at_unix_secs)); Ok(Some(items)) } @@ -592,6 +679,59 @@ mod tests { .expect("billing context should build") } + #[tokio::test] + async fn list_user_plan_entitlements_history_includes_inactive_only_for_selected_user() { + let repository = InMemoryBillingReadRepository::default(); + let now = super::current_unix_secs(); + for (id, user_id, status, expires_at) in [ + ("active", "user-1", "active", now + 3600), + ("expired", "user-1", "active", now - 60), + ("revoked", "user-1", "revoked", now + 3600), + ("replaced", "user-1", "replaced", now + 3600), + ("another-user", "user-2", "revoked", now + 3600), + ] { + repository.entitlements_by_id.write().unwrap().insert( + id.to_string(), + super::UserPlanEntitlementRecord { + id: id.to_string(), + user_id: user_id.to_string(), + plan_id: "plan-1".to_string(), + payment_order_id: format!("order-{id}"), + status: status.to_string(), + starts_at_unix_secs: now - 120, + expires_at_unix_secs: expires_at, + entitlements_snapshot: json!([]), + created_at_unix_secs: now - 120, + updated_at_unix_secs: now - 60, + }, + ); + } + + let active = repository + .list_user_plan_entitlements("user-1") + .await + .unwrap() + .unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, "active"); + let explicit_active = repository + .list_user_plan_entitlements_with_history("user-1", false) + .await + .unwrap() + .unwrap(); + assert_eq!(active, explicit_active); + let history = repository + .list_user_plan_entitlements_with_history("user-1", true) + .await + .unwrap() + .unwrap(); + assert_eq!(history.len(), 4); + assert!(history.iter().all(|item| item.user_id == "user-1")); + for expected in ["active", "expired", "revoked", "replaced"] { + assert!(history.iter().any(|item| item.id == expected)); + } + } + #[tokio::test] async fn falls_back_to_provider_without_key_scope() { let repository = InMemoryBillingReadRepository::seed(vec![sample_context()]); @@ -779,3 +919,108 @@ mod tests { assert_eq!(after, expected); } } + +#[cfg(test)] +mod provider_expense_tests { + use super::*; + use crate::repository::billing::{ProviderExpenseInput, ProviderExpenseQuery}; + fn input() -> ProviderExpenseInput { + ProviderExpenseInput { + client_request_id: uuid::Uuid::new_v4().to_string(), + provider_id: "provider-1".into(), + provider_name: "Supplier".into(), + kind: "recharge".into(), + amount: "12.34000000".into(), + currency: "USD".into(), + paid_at_unix_ms: 1000, + period_start_unix_ms: None, + period_end_unix_ms: None, + note: Some("test".into()), + external_reference: None, + created_by: Some("admin-1".into()), + } + } + #[tokio::test] + async fn provider_expense_retries_and_void_are_idempotent_without_erasing_audit() { + let repo = InMemoryBillingReadRepository::default(); + let input = input(); + let AdminBillingMutationOutcome::Applied(first) = + repo.create_provider_expense(&input).await.unwrap() + else { + panic!("expected record") + }; + let mut retried = input.clone(); + retried.provider_name = "Renamed supplier".into(); + let AdminBillingMutationOutcome::Applied(second) = + repo.create_provider_expense(&retried).await.unwrap() + else { + panic!("expected retry") + }; + assert_eq!(first, second); + retried.amount = "99".into(); + assert!(matches!( + repo.create_provider_expense(&retried).await.unwrap(), + AdminBillingMutationOutcome::Invalid(_) + )); + let query = ProviderExpenseQuery { + from_unix_ms: 0, + to_unix_ms: 2000, + limit: 20, + offset: 0, + }; + assert_eq!( + repo.list_provider_expenses(&query) + .await + .unwrap() + .unwrap() + .total, + 1 + ); + let AdminBillingMutationOutcome::Applied(voided) = repo + .void_provider_expense(&first.id, Some("admin-2")) + .await + .unwrap() + else { + panic!("expected void") + }; + let AdminBillingMutationOutcome::Applied(again) = repo + .void_provider_expense(&first.id, Some("admin-3")) + .await + .unwrap() + else { + panic!("expected retry void") + }; + assert_eq!(voided, again); + assert_eq!(again.voided_by.as_deref(), Some("admin-2")); + let page = repo.list_provider_expenses(&query).await.unwrap().unwrap(); + assert_eq!(page.total, 0); + assert!(page.totals.is_empty()); + let AdminBillingMutationOutcome::Applied(after_void) = + repo.create_provider_expense(&input).await.unwrap() + else { + panic!("expected original tombstone") + }; + assert_eq!(after_void, again); + } + #[tokio::test] + async fn provider_expense_duplicate_submissions_record_once() { + let repo = InMemoryBillingReadRepository::default(); + let input = input(); + let (a, b) = tokio::join!( + repo.create_provider_expense(&input), + repo.create_provider_expense(&input) + ); + let (AdminBillingMutationOutcome::Applied(a), AdminBillingMutationOutcome::Applied(b)) = + (a.unwrap(), b.unwrap()) + else { + panic!("expected records") + }; + assert_eq!(a.id, b.id); + let mut invalid = input.clone(); + invalid.period_start_unix_ms = Some(100); + assert!(matches!( + repo.create_provider_expense(&invalid).await.unwrap(), + AdminBillingMutationOutcome::Invalid(_) + )); + } +} diff --git a/crates/aether-data/runtime/src/repository/settlement/memory.rs b/crates/aether-data/runtime/src/repository/settlement/memory.rs index a188493fe..33032c6b2 100644 --- a/crates/aether-data/runtime/src/repository/settlement/memory.rs +++ b/crates/aether-data/runtime/src/repository/settlement/memory.rs @@ -10,7 +10,7 @@ use super::{ ReserveUsagePolicyCostInput, ReserveUsagePolicyCostOutcome, ReserveUsagePolicyRequestInput, ReserveUsagePolicyRequestOutcome, SettlementWriteRepository, StoredUsagePolicyCostReservation, StoredUsagePolicyRequestAdmission, StoredUsageSettlement, UsagePolicyCostReservationState, - UsagePolicyRequestAdmissionState, UsageSettlementInput, SETTLEMENT_EPSILON_USD, + UsagePolicyRequestAdmissionState, UsageSettlementInput, }; use crate::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot}; use crate::DataLayerError; @@ -511,9 +511,7 @@ impl SettlementWriteRepository for InMemorySettlementRepository { settlement.wallet_recharge_balance_after = Some(wallet.balance); settlement.wallet_gift_balance_after = Some(wallet.gift_balance); settlement.wallet_balance_after = Some(wallet.balance + wallet.gift_balance); - } else if final_billing_status == "settled" - && billable_cost_usd > SETTLEMENT_EPSILON_USD - { + } else if final_billing_status == "settled" && billable_cost_usd > 0.0 { final_billing_status = "insufficient_quota".to_string(); settlement.billing_status = final_billing_status.clone(); } diff --git a/crates/aether-data/runtime/src/repository/usage/memory.rs b/crates/aether-data/runtime/src/repository/usage/memory.rs index af0fa9b63..d10d9ddd8 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory.rs @@ -44,17 +44,33 @@ use super::{ use crate::repository::auth::InMemoryAuthApiKeySnapshotRepository; use crate::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use crate::DataLayerError; +mod analytics; +mod dashboard_summary; #[derive(Debug, Default)] pub struct InMemoryUsageReadRepository { + dashboard_projection: RwLock, by_request_id: RwLock>, detached_bodies: RwLock>, provider_usage_windows: RwLock>, auth_api_keys: Option>, provider_catalog: Option>, + analytics_users: RwLock>, + analytics_candidates: + RwLock>, + analytics_allocations: RwLock< + BTreeMap, + >, } impl InMemoryUsageReadRepository { + fn analytics_key_flags(&self) -> BTreeMap { + self.auth_api_keys + .as_ref() + .map(|repository| repository.standalone_flags()) + .unwrap_or_default() + } + pub fn seed(items: I) -> Self where I: IntoIterator, @@ -66,11 +82,15 @@ impl InMemoryUsageReadRepository { by_request_id.insert(item.request_id.clone(), item); } Self { + dashboard_projection: Default::default(), by_request_id: RwLock::new(by_request_id), detached_bodies: RwLock::new(BTreeMap::new()), provider_usage_windows: RwLock::new(Vec::new()), auth_api_keys: None, provider_catalog: None, + analytics_users: Default::default(), + analytics_candidates: Default::default(), + analytics_allocations: Default::default(), } } @@ -119,11 +139,15 @@ impl InMemoryUsageReadRepository { by_request_id.insert(request_id, item); } Self { + dashboard_projection: Default::default(), by_request_id: RwLock::new(by_request_id), detached_bodies: RwLock::new(detached_bodies), provider_usage_windows: RwLock::new(Vec::new()), auth_api_keys: None, provider_catalog: None, + analytics_users: Default::default(), + analytics_candidates: Default::default(), + analytics_allocations: Default::default(), } } @@ -132,11 +156,15 @@ impl InMemoryUsageReadRepository { I: IntoIterator, { Self { + dashboard_projection: self.dashboard_projection, by_request_id: self.by_request_id, detached_bodies: self.detached_bodies, provider_usage_windows: RwLock::new(items.into_iter().collect()), auth_api_keys: self.auth_api_keys, provider_catalog: self.provider_catalog, + analytics_users: self.analytics_users, + analytics_candidates: self.analytics_candidates, + analytics_allocations: self.analytics_allocations, } } @@ -244,7 +272,48 @@ fn usage_has_admin_unknown_model_or_provider(item: &StoredRequestUsageAudit) -> usage_admin_unknown_label(&item.model) || usage_admin_unknown_label(&item.provider_name) } -fn usage_matches_list_query(item: &StoredRequestUsageAudit, query: &UsageAuditListQuery) -> bool { +fn usage_matches_list_query( + item: &StoredRequestUsageAudit, + query: &UsageAuditListQuery, + keys: &BTreeMap, +) -> bool { + if query + .provider_id + .as_ref() + .is_some_and(|id| item.provider_id.as_ref() != Some(id)) + || query + .endpoint_kind + .as_ref() + .is_some_and(|value| item.endpoint_kind.as_ref() != Some(value)) + || query + .request_type + .as_ref() + .is_some_and(|value| item.request_type.as_ref() != Some(value)) + || query + .slow_threshold_ms + .is_some_and(|value| item.response_time_ms.is_none_or(|latency| latency < value)) + || query + .has_format_conversion + .is_some_and(|value| item.has_format_conversion != value) + || query + .api_key_id + .as_ref() + .is_some_and(|id| item.api_key_id.as_ref() != Some(id)) + || query + .request_id + .as_ref() + .is_some_and(|id| item.request_id != *id) + || query + .attribution_kind + .as_deref() + .is_some_and(|kind| analytics::attribution(item, keys) != kind) + || query + .actor_user_id + .as_deref() + .is_some_and(|id| analytics::actor(item, keys) != Some(id)) + { + return false; + } // The field is historically named `created_at_unix_ms`, but usage audit rows // across gateway handlers, SQL repositories and tests are stored as epoch seconds. if let Some(created_from_unix_secs) = query.created_from_unix_secs { @@ -332,7 +401,45 @@ fn usage_matches_list_query(item: &StoredRequestUsageAudit, query: &UsageAuditLi fn usage_matches_keyword_search_query( item: &StoredRequestUsageAudit, query: &UsageAuditKeywordSearchQuery, + keys: &BTreeMap, ) -> bool { + if query + .provider_id + .as_ref() + .is_some_and(|id| item.provider_id.as_ref() != Some(id)) + || query + .endpoint_kind + .as_ref() + .is_some_and(|value| item.endpoint_kind.as_ref() != Some(value)) + || query + .request_type + .as_ref() + .is_some_and(|value| item.request_type.as_ref() != Some(value)) + || query + .slow_threshold_ms + .is_some_and(|value| item.response_time_ms.is_none_or(|latency| latency < value)) + || query + .has_format_conversion + .is_some_and(|value| item.has_format_conversion != value) + || query + .api_key_id + .as_ref() + .is_some_and(|id| item.api_key_id.as_ref() != Some(id)) + || query + .request_id + .as_ref() + .is_some_and(|id| item.request_id != *id) + || query + .attribution_kind + .as_deref() + .is_some_and(|kind| analytics::attribution(item, keys) != kind) + || query + .actor_user_id + .as_deref() + .is_some_and(|id| analytics::actor(item, keys) != Some(id)) + { + return false; + } if let Some(created_from_unix_secs) = query.created_from_unix_secs { if item.created_at_unix_ms < created_from_unix_secs { return false; @@ -1127,6 +1234,38 @@ fn usage_provider_aggregation_identity( #[async_trait] impl UsageReadRepository for InMemoryUsageReadRepository { + async fn query_dashboard_summary( + &self, + query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery, + ) -> Result { + self.dashboard_summary_query(query) + } + + async fn query_dashboard_analytics( + &self, + query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery, + ) -> Result< + aether_data_contracts::repository::usage::StoredUsageDashboardAnalytics, + DataLayerError, + > { + self.dashboard_analytics_query(query) + } + + async fn query_usage_analytics( + &self, + query: &aether_data_contracts::repository::usage::UsageAnalyticsQuery, + ) -> Result + { + self.analytics_query(query) + } + + async fn summarize_health_observations( + &self, + query: &aether_data_contracts::repository::usage::HealthObservationQuery, + ) -> Result + { + self.health_observations(query) + } async fn find_by_id( &self, id: &str, @@ -1205,12 +1344,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository { &self, query: &UsageAuditListQuery, ) -> Result, DataLayerError> { + let keys = self.analytics_key_flags(); let mut items: Vec<_> = self .by_request_id .read() .expect("usage repository lock") .values() - .filter(|item| usage_matches_list_query(item, query)) + .filter(|item| usage_matches_list_query(item, query, &keys)) .cloned() .collect(); sort_usage_items(&mut items, query.newest_first); @@ -1231,12 +1371,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository { &self, query: &UsageAuditKeywordSearchQuery, ) -> Result, DataLayerError> { + let keys = self.analytics_key_flags(); let mut items: Vec<_> = self .by_request_id .read() .expect("usage repository lock") .values() - .filter(|item| usage_matches_keyword_search_query(item, query)) + .filter(|item| usage_matches_keyword_search_query(item, query, &keys)) .cloned() .collect(); sort_usage_items(&mut items, query.newest_first); @@ -1254,12 +1395,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository { } async fn count_usage_audits(&self, query: &UsageAuditListQuery) -> Result { + let keys = self.analytics_key_flags(); Ok(self .by_request_id .read() .expect("usage repository lock") .values() - .filter(|item| usage_matches_list_query(item, query)) + .filter(|item| usage_matches_list_query(item, query, &keys)) .count() as u64) } @@ -1267,12 +1409,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository { &self, query: &UsageAuditKeywordSearchQuery, ) -> Result { + let keys = self.analytics_key_flags(); Ok(self .by_request_id .read() .expect("usage repository lock") .values() - .filter(|item| usage_matches_keyword_search_query(item, query)) + .filter(|item| usage_matches_keyword_search_query(item, query, &keys)) .count() as u64) } @@ -3281,6 +3424,8 @@ impl UsageWriteRepository for InMemoryUsageReadRepository { finalized_at_unix_secs: usage.finalized_at_unix_secs, }; + self.dashboard_projection.write().expect("dashboard projection lock") + .record(&stored, &self.analytics_key_flags()); by_request_id.insert(stored.request_id.clone(), stored.clone()); if let Some(auth_api_keys) = self.auth_api_keys.as_ref() { let before_contribution = existing.as_ref().and_then(api_key_usage_contribution); diff --git a/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs b/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs new file mode 100644 index 000000000..72414013d --- /dev/null +++ b/crates/aether-data/runtime/src/repository/usage/memory/analytics.rs @@ -0,0 +1,1080 @@ +use super::InMemoryUsageReadRepository; +use aether_data_contracts::repository::candidates::{ + RequestCandidateStatus, StoredRequestCandidate, +}; +use aether_data_contracts::repository::usage::*; +use aether_data_contracts::repository::users::StoredUserSummary; +use aether_data_contracts::DataLayerError; +use chrono::{DateTime, TimeZone, Timelike, Utc}; +use std::collections::{BTreeMap, BTreeSet}; + +fn metadata<'a>(row: &'a StoredRequestUsageAudit, group: &str, field: &str) -> Option<&'a str> { + row.request_metadata + .as_ref()? + .get(group)? + .get(field)? + .as_str() +} +fn standalone(row: &StoredRequestUsageAudit, keys: &BTreeMap) -> Option { + row.api_key_id + .as_ref() + .and_then(|id| keys.get(id).copied()) + .or_else(|| { + row.request_metadata + .as_ref()? + .get("analytics_attribution")? + .get("is_standalone")? + .as_bool() + }) + .or_else(|| { + row.request_metadata + .as_ref()? + .get("api_key_is_standalone")? + .as_bool() + }) + .or_else(|| row.api_key_id.is_none().then_some(false)) +} +pub(super) fn actor<'a>( + row: &'a StoredRequestUsageAudit, + keys: &BTreeMap, +) -> Option<&'a str> { + (standalone(row, keys) == Some(false)) + .then_some(row.user_id.as_deref()) + .flatten() +} +pub(super) fn attribution( + row: &StoredRequestUsageAudit, + keys: &BTreeMap, +) -> &'static str { + match (row.user_id.as_ref(), standalone(row, keys)) { + (Some(_), Some(false)) => "employee", + (Some(_), Some(true)) => "standalone", + _ => "unknown", + } +} +fn available(row: &StoredRequestUsageAudit, key: &str) -> bool { + row.request_metadata + .as_ref() + .and_then(|metadata| metadata.get(key)) + .and_then(serde_json::Value::as_bool) + != Some(false) +} +// The legacy audit contract stores epoch seconds despite its historical field name. +fn usage_started_ms(row: &StoredRequestUsageAudit) -> u64 { + row.created_at_unix_ms.saturating_mul(1000) +} + +fn timestamp(ms: u64) -> String { + DateTime::::from_timestamp_millis(ms as i64) + .unwrap_or_default() + .to_rfc3339() +} +fn amount_units(value: &Option) -> Option { + let (whole, fraction) = value.as_ref()?.split_once('.')?; + if fraction.len() > 8 { + return None; + } + let sign = if whole.starts_with('-') { -1 } else { 1 }; + Some( + sign * (whole.trim_start_matches('-').parse::().ok()? * 100_000_000 + + fraction.parse::().ok()? * 10_i128.pow((8 - fraction.len()) as u32)), + ) +} + +fn apply_allocations( + metrics: &mut UsageAnalyticsMetrics, + rows: &[&StoredRequestUsageAudit], + allocations: &BTreeMap, +) { + let selected = rows + .iter() + .filter_map(|row| allocations.get(&row.request_id)) + .collect::>(); + metrics.allocation_available_count = selected + .iter() + .filter(|allocation| allocation.complete) + .count() as u64; + let sum = |selected: &[&UsageAnalyticsAllocation], + read: fn(&UsageAnalyticsAllocation) -> &Option| { + let values = selected + .iter() + .filter_map(|allocation| amount_units(read(allocation))) + .collect::>(); + if values.is_empty() { + return None; + } + let sum: i128 = values.iter().sum(); + Some(format!( + "{}{}.{:08}", + if sum < 0 { "-" } else { "" }, + sum.abs() / 100_000_000, + sum.abs() % 100_000_000 + )) + }; + metrics.quota_covered_amount = sum(&selected, |a| &a.quota_covered_amount); + metrics.wallet_consumed_amount = sum(&selected, |a| &a.wallet_consumed_amount); + metrics.wallet_debit_amount = sum(&selected, |a| &a.wallet_debit_amount); + metrics.wallet_recharge_debit_amount = sum(&selected, |a| &a.wallet_recharge_debit_amount); + metrics.wallet_gift_debit_amount = sum(&selected, |a| &a.wallet_gift_debit_amount); + metrics.wallet_overdraft_amount = sum(&selected, |a| &a.wallet_overdraft_amount); + let priced = rows + .iter() + .filter(|row| { + available(row, USAGE_AVAILABLE_METADATA_KEY) + && available(row, USAGE_PRICING_AVAILABLE_METADATA_KEY) + }) + .filter_map(|row| allocations.get(&row.request_id)) + .collect::>(); + metrics.cache_read_cost_amount = sum(&priced, |a| &a.cache_read_cost_amount); + metrics.cache_creation_cost_amount = sum(&priced, |a| &a.cache_creation_cost_amount); + metrics.cache_estimated_full_cost_amount = + sum(&priced, |a| &a.cache_estimated_full_cost_amount); + metrics.cache_pricing_available_count = priced + .iter() + .filter(|allocation| allocation.cache_estimated_full_cost_amount.is_some()) + .count() as u64; +} +fn decimal_sum( + rows: &[&StoredRequestUsageAudit], + value: impl Fn(&StoredRequestUsageAudit) -> f64, +) -> Option { + let amounts = rows + .iter() + .filter(|row| { + available(row, USAGE_PRICING_AVAILABLE_METADATA_KEY) && row.billing_status == "settled" + }) + .map(|row| (value(row) * 100_000_000.0).round() as i128) + .collect::>(); + if amounts.is_empty() { + None + } else { + let sum: i128 = amounts.iter().sum(); + Some(format!( + "{}{}.{:08}", + if sum < 0 { "-" } else { "" }, + sum.abs() / 100_000_000, + sum.abs() % 100_000_000 + )) + } +} +fn metrics( + rows: &[&StoredRequestUsageAudit], + slow: u64, + keys: &BTreeMap, +) -> UsageAnalyticsMetrics { + let mut metrics = UsageAnalyticsMetrics::default(); + let mut latencies = Vec::new(); + let mut first_bytes = Vec::new(); + let mut users = BTreeSet::new(); + for row in rows { + metrics.request_count += 1; + match row.status.as_str() { + "completed" => metrics.successful_request_count += 1, + "failed" => metrics.failed_request_count += 1, + "cancelled" => metrics.cancelled_request_count += 1, + _ => metrics.in_flight_request_count += 1, + } + if available(row, USAGE_AVAILABLE_METADATA_KEY) { + metrics.usage_available_count += 1; + match metadata(row, "analytics_measurement", "source") { + Some("reported") => metrics.reported_usage_count += 1, + Some("estimated") => metrics.estimated_usage_count += 1, + Some("mixed") => metrics.mixed_usage_count += 1, + _ => metrics.unknown_usage_count += 1, + } + metrics.input_tokens += row.input_tokens; + metrics.output_tokens += row.output_tokens; + metrics.total_tokens += row.total_tokens; + metrics.cache_read_input_tokens += row.cache_read_input_tokens; + metrics.cache_creation_input_tokens += row.cache_creation_input_tokens; + } else { + metrics.unknown_usage_count += 1; + } + if row.billing_status == "settled" { + metrics.settled_count += 1; + if available(row, USAGE_PRICING_AVAILABLE_METADATA_KEY) { + metrics.pricing_available_count += 1; + } + } + if let Some(actor) = actor(row, keys) { + users.insert(actor); + metrics.trusted_attribution_count += 1; + } + if let Some(value) = row.response_time_ms { + latencies.push(value); + metrics.latency_sum_ms += value as f64; + if value >= slow { + metrics.slow_request_count += 1; + } + } + if let Some(value) = row.first_byte_time_ms { + first_bytes.push(value); + metrics.first_byte_sum_ms += value as f64; + } + let upstream_is_stream = row + .request_metadata + .as_ref() + .and_then(|metadata| metadata.get("upstream_is_stream")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(row.is_stream); + if upstream_is_stream + && available(row, USAGE_AVAILABLE_METADATA_KEY) + && row.output_tokens > 0 + { + if let (Some(duration), Some(first_byte)) = + (row.response_time_ms, row.first_byte_time_ms) + { + if duration > first_byte { + metrics.output_tps_sample_count += 1; + metrics.output_tps_sum += + row.output_tokens as f64 * 1000.0 / (duration - first_byte) as f64; + } + } + } + if row.status == "failed" + && metadata(row, "analytics_failure", "origin") + .is_some_and(|origin| origin != "unknown") + { + metrics.classified_failure_count += 1; + } + } + latencies.sort_unstable(); + let percentile = |fraction: f64| { + if latencies.is_empty() { + return None; + } + let rank = (latencies.len() - 1) as f64 * fraction; + let lower = rank.floor() as usize; + let upper = rank.ceil() as usize; + Some(latencies[lower] as f64 + (latencies[upper] - latencies[lower]) as f64 * rank.fract()) + }; + metrics.latency_sample_count = latencies.len() as u64; + metrics.latency_p50_ms = percentile(0.5); + metrics.latency_p95_ms = percentile(0.95); + metrics.latency_p99_ms = percentile(0.99); + metrics.latency_p90_ms = percentile(0.9); + first_bytes.sort_unstable(); + let first_percentile = |fraction: f64| { + if first_bytes.is_empty() { + return None; + } + let rank = (first_bytes.len() - 1) as f64 * fraction; + let lower = rank.floor() as usize; + let upper = rank.ceil() as usize; + Some( + first_bytes[lower] as f64 + + (first_bytes[upper] - first_bytes[lower]) as f64 * rank.fract(), + ) + }; + metrics.first_byte_sample_count = first_bytes.len() as u64; + metrics.first_byte_p90_ms = first_percentile(0.9); + metrics.first_byte_p99_ms = first_percentile(0.99); + metrics.usage_active_users = users.len() as u64; + metrics.rated_amount = decimal_sum(rows, |row| row.total_cost_usd); + metrics.billable_amount = decimal_sum(rows, |row| row.actual_total_cost_usd); + metrics +} + +fn dashboard_total_metrics( + rows: &[&StoredRequestUsageAudit], + allocations: &BTreeMap, +) -> UsageAnalyticsMetrics { + let mut metrics = UsageAnalyticsMetrics { + request_count: rows.len() as u64, + billable_amount: decimal_sum(rows, |row| row.actual_total_cost_usd), + ..Default::default() + }; + for row in rows { + if available(row, USAGE_AVAILABLE_METADATA_KEY) { + metrics.usage_available_count += 1; + metrics.total_tokens += row.total_tokens; + } + if row.billing_status == "settled" { + metrics.settled_count += 1; + if available(row, USAGE_PRICING_AVAILABLE_METADATA_KEY) { + metrics.pricing_available_count += 1; + } + } + if allocations + .get(&row.request_id) + .is_some_and(|allocation| allocation.complete) + { + metrics.allocation_available_count += 1; + } + } + metrics +} +fn matches( + row: &StoredRequestUsageAudit, + query: &UsageAnalyticsQuery, + keys: &BTreeMap, +) -> bool { + usage_started_ms(row) >= query.from_unix_ms + && usage_started_ms(row) < query.to_unix_ms + && metadata(row, "analytics_attribution", "record_kind") != Some("session") + && query + .actor_user_id + .as_deref() + .is_none_or(|value| actor(row, keys) == Some(value)) + && query + .credential_owner_id + .as_deref() + .is_none_or(|value| row.user_id.as_deref() == Some(value)) + && query + .attribution_kind + .as_deref() + .is_none_or(|value| attribution(row, keys) == value) + && query + .api_key_id + .as_deref() + .is_none_or(|value| row.api_key_id.as_deref() == Some(value)) + && query + .model + .as_deref() + .is_none_or(|value| row.model == value) + && query + .provider_id + .as_deref() + .is_none_or(|value| row.provider_id.as_deref() == Some(value)) + && query + .api_format + .as_deref() + .is_none_or(|value| row.api_format.as_deref() == Some(value)) + && query + .endpoint_kind + .as_deref() + .is_none_or(|value| row.endpoint_kind.as_deref() == Some(value)) + && query + .request_type + .as_deref() + .is_none_or(|value| row.request_type.as_deref() == Some(value)) + && query + .status + .as_deref() + .is_none_or(|value| row.status == value) + && query.is_stream.is_none_or(|value| row.is_stream == value) + && query + .has_format_conversion + .is_none_or(|value| row.has_format_conversion == value) +} + +impl InMemoryUsageReadRepository { + pub fn with_analytics_allocations( + self, + allocations: impl IntoIterator, + ) -> Self { + *self + .analytics_allocations + .write() + .expect("analytics allocations lock") = allocations + .into_iter() + .map(|allocation| (allocation.request_id.clone(), allocation)) + .collect(); + self + } + pub fn with_analytics_users(self, users: impl IntoIterator) -> Self { + *self.analytics_users.write().expect("analytics roster lock") = users.into_iter().collect(); + self + } + pub fn with_analytics_candidates( + self, + candidates: impl IntoIterator, + ) -> Self { + *self + .analytics_candidates + .write() + .expect("analytics candidates lock") = candidates.into_iter().collect(); + self + } + + pub(super) fn analytics_query( + &self, + query: &UsageAnalyticsQuery, + ) -> Result { + query.validate()?; + let keys = self.analytics_key_flags(); + let rows = self + .by_request_id + .read() + .map_err(|_| DataLayerError::UnexpectedValue("usage lock poisoned".into()))?; + let users = self + .analytics_users + .read() + .map_err(|_| DataLayerError::UnexpectedValue("users lock poisoned".into()))?; + let filtered = rows + .values() + .filter(|row| matches(row, query, &keys)) + .collect::>(); + let allocations = self + .analytics_allocations + .read() + .map_err(|_| DataLayerError::UnexpectedValue("allocation lock poisoned".into()))?; + let metrics = |rows: &[&StoredRequestUsageAudit], slow| { + let mut result = metrics(rows, slow, &keys); + apply_allocations(&mut result, rows, &allocations); + result + }; + let mut summary = metrics(&filtered, query.slow_threshold_ms.unwrap_or(5000)); + summary.enabled_users = users + .iter() + .filter(|user| user.is_active && !user.is_deleted) + .count() as u64; + let mut result = StoredUsageAnalytics { + total: summary.request_count, + summary, + generated_at: Utc::now().to_rfc3339(), + read_revision: format!( + "memory:{}:{}", + rows.len(), + rows.values() + .map(|row| row.updated_at_unix_secs) + .max() + .unwrap_or(0) + ), + ..Default::default() + }; + let tz = query + .timezone + .parse::() + .expect("validated timezone"); + match query.view { + UsageAnalyticsView::Summary => {} + UsageAnalyticsView::Consumption => { + let mut sorted = filtered.clone(); + sorted.sort_by(|left, right| { + let order = left.created_at_unix_ms.cmp(&right.created_at_unix_ms); + (if query.descending { + order.reverse() + } else { + order + }) + .then(left.request_id.cmp(&right.request_id)) + }); + result.consumption = sorted + .into_iter() + .skip(query.offset as usize) + .take(query.limit as usize) + .map(|row| { + let metric = metrics(&[row], 5000); + UsageAnalyticsConsumption { + id: row.id.clone(), + request_id: row.request_id.clone(), + started_at: timestamp(usage_started_ms(row)), + user_id: actor(row, &keys).map(str::to_owned), + credential_owner_id: row.user_id.clone(), + model: row.model.clone(), + provider: Some(row.provider_name.clone()), + provider_id: row.provider_id.clone(), + api_key_id: row.api_key_id.clone(), + status: row.status.clone(), + settlement_status: row.billing_status.clone(), + attribution_kind: attribution(row, &keys).into(), + attribution_source: match attribution(row, &keys) { + "employee" => "user_account", + "standalone" => "standalone_key", + _ => "unknown", + } + .into(), + rated_amount: metric.rated_amount, + billable_amount: metric.billable_amount, + quota_covered_amount: metric.quota_covered_amount, + wallet_consumed_amount: metric.wallet_consumed_amount, + wallet_debit_amount: metric.wallet_debit_amount, + } + }) + .collect(); + } + UsageAnalyticsView::Users => { + let mut roster = users + .iter() + .filter(|user| { + !user.is_deleted + && query + .user_is_active + .is_none_or(|value| user.is_active == value) + && query + .actor_user_id + .as_ref() + .or(query.credential_owner_id.as_ref()) + .is_none_or(|value| user.id == *value) + && query.search.as_ref().is_none_or(|search| { + user.username + .to_lowercase() + .contains(&search.to_lowercase()) + || user.email.as_ref().is_some_and(|email| { + email.to_lowercase().contains(&search.to_lowercase()) + }) + }) + }) + .filter_map(|user| { + let usage = filtered + .iter() + .copied() + .filter(|row| { + if query.actor_user_id.is_some() + || query.attribution_kind.as_deref() == Some("employee") + { + actor(row, &keys) == Some(user.id.as_str()) + } else { + row.user_id.as_ref() == Some(&user.id) + } + }) + .collect::>(); + if query + .has_usage + .is_some_and(|value| value == usage.is_empty()) + { + return None; + } + let days = usage + .iter() + .map(|row| { + DateTime::::from_timestamp_millis(usage_started_ms(row) as i64) + .unwrap_or_default() + .with_timezone(&tz) + .date_naive() + }) + .collect::>(); + Some(UsageAnalyticsUser { + user_id: user.id.clone(), + username: user.username.clone(), + email: user.email.clone(), + is_active: user.is_active, + last_used_at: usage + .iter() + .map(|row| usage_started_ms(row)) + .max() + .map(timestamp), + active_days: days.len() as u64, + metrics: metrics(&usage, query.slow_threshold_ms.unwrap_or(5000)), + // This adapter has no wallet/payment read model. Do + // not invent zero balances or zero credited orders. + finance: None, + }) + }) + .collect::>(); + roster.sort_by(|a, b| { + let order = match query.sort { + UsageAnalyticsSort::Requests => { + a.metrics.request_count.cmp(&b.metrics.request_count) + } + UsageAnalyticsSort::Tokens => { + a.metrics.total_tokens.cmp(&b.metrics.total_tokens) + } + UsageAnalyticsSort::ActiveDays => a.active_days.cmp(&b.active_days), + UsageAnalyticsSort::Username => a.username.cmp(&b.username), + UsageAnalyticsSort::LastUsed | UsageAnalyticsSort::StartedAt => { + a.last_used_at.cmp(&b.last_used_at) + } + UsageAnalyticsSort::BillableAmount => { + amount_units(&a.metrics.billable_amount) + .cmp(&amount_units(&b.metrics.billable_amount)) + } + }; + (if query.descending { + order.reverse() + } else { + order + }) + .then(a.user_id.cmp(&b.user_id)) + }); + result.total = roster.len() as u64; + let selected_ids = roster + .iter() + .map(|user| user.user_id.as_str()) + .collect::>(); + let selected_usage = filtered + .iter() + .copied() + .filter(|row| { + let subject = if query.actor_user_id.is_some() + || query.attribution_kind.as_deref() == Some("employee") + { + actor(row, &keys) + } else { + row.user_id.as_deref() + }; + subject.is_some_and(|id| selected_ids.contains(id)) + }) + .collect::>(); + result.summary = metrics(&selected_usage, query.slow_threshold_ms.unwrap_or(5000)); + result.summary.enabled_users = + roster.iter().filter(|user| user.is_active).count() as u64; + result.user_summary = Some(UsageAnalyticsUserSummary { + user_count: result.total, + active_user_count: roster + .iter() + .filter(|user| user.metrics.request_count > 0) + .count() as u64, + metrics: result.summary.clone(), + }); + result.users = roster + .into_iter() + .skip(query.offset as usize) + .take(query.limit as usize) + .collect(); + } + UsageAnalyticsView::Timeseries + | UsageAnalyticsView::Performance + | UsageAnalyticsView::DashboardCharts + | UsageAnalyticsView::Breakdown => { + let mut groups = BTreeMap::, Vec<&StoredRequestUsageAudit>>::new(); + for row in filtered.iter().copied() { + let group = if query.view != UsageAnalyticsView::Breakdown { + let local = + DateTime::::from_timestamp_millis(usage_started_ms(row) as i64) + .unwrap_or_default() + .with_timezone(&tz); + let bucket = match query.granularity { + UsageAnalyticsGranularity::Hour => local + .with_timezone(&Utc) + .with_minute(0) + .and_then(|d| d.with_second(0)) + .and_then(|d| d.with_nanosecond(0)) + .map(|d| d.with_timezone(&Utc)), + UsageAnalyticsGranularity::Day => Some( + UsageDashboardAnalyticsQuery { + timezone: query.timezone.clone(), + } + .today_start(local.with_timezone(&Utc))?, + ), + }; + bucket.map(|d| d.to_rfc3339()) + } else { + match query.group_by { + UsageAnalyticsGroupBy::Model => Some(row.model.clone()), + UsageAnalyticsGroupBy::Provider => row.provider_id.clone(), + UsageAnalyticsGroupBy::ApiKey => row.api_key_id.clone(), + UsageAnalyticsGroupBy::Attribution => { + Some(attribution(row, &keys).into()) + } + UsageAnalyticsGroupBy::ApiFormat => row.api_format.clone(), + UsageAnalyticsGroupBy::RequestType => row.request_type.clone(), + } + }; + groups.entry(group).or_default().push(row); + } + let mut grouped = groups + .into_iter() + .map(|(id, rows)| UsageAnalyticsRow { + label: id.clone(), + bucket_start: (query.view != UsageAnalyticsView::Breakdown) + .then(|| id.clone()) + .flatten(), + id, + metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + }) + .collect::>(); + if query.view == UsageAnalyticsView::Breakdown { + grouped.sort_by(|a, b| { + let order = match query.sort { + UsageAnalyticsSort::BillableAmount => { + amount_units(&a.metrics.billable_amount) + .cmp(&amount_units(&b.metrics.billable_amount)) + } + UsageAnalyticsSort::Tokens => { + a.metrics.total_tokens.cmp(&b.metrics.total_tokens) + } + _ => a.metrics.request_count.cmp(&b.metrics.request_count), + }; + (if query.descending { + order.reverse() + } else { + order + }) + .then(a.id.cmp(&b.id)) + }); + } + result.total = grouped.len() as u64; + result.rows = if query.view == UsageAnalyticsView::Breakdown { + grouped + .into_iter() + .skip(query.offset as usize) + .take(query.limit as usize) + .collect() + } else { + grouped + }; + } + } + if matches!( + query.view, + UsageAnalyticsView::Timeseries + | UsageAnalyticsView::Performance + | UsageAnalyticsView::DashboardCharts + ) { + fill_usage_analytics_timeseries(query, &mut result.rows); + result.total = result.rows.len() as u64; + } + if query.view == UsageAnalyticsView::DashboardCharts { + let mut providers = BTreeMap::, Vec<&StoredRequestUsageAudit>>::new(); + let mut models = BTreeMap::<(String, String), Vec<&StoredRequestUsageAudit>>::new(); + for row in &filtered { + let at = DateTime::::from_timestamp_millis(usage_started_ms(row) as i64) + .expect("usage timestamp"); + let bucket = if query.granularity == UsageAnalyticsGranularity::Hour { + at.with_minute(0) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + .expect("hour") + } else { + UsageDashboardAnalyticsQuery { + timezone: query.timezone.clone(), + } + .today_start(at)? + }; + providers + .entry(row.provider_id.clone()) + .or_default() + .push(row); + models + .entry((bucket.to_rfc3339(), row.model.clone())) + .or_default() + .push(row); + } + if providers.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT + || models.len() > USAGE_DASHBOARD_CHART_ROW_LIMIT + { + return Err(DataLayerError::InvalidInput( + "dashboard chart exceeds 10000 groups; narrow the range".into(), + )); + } + result.provider_rows = providers + .into_iter() + .map(|(id, rows)| UsageAnalyticsRow { + label: rows.first().map(|row| row.provider_name.clone()), + id, + bucket_start: None, + metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + }) + .collect(); + result.model_rows = models + .into_iter() + .map(|((bucket, model), rows)| UsageAnalyticsRow { + id: Some(model.clone()), + label: Some(model), + bucket_start: Some(bucket), + metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + }) + .collect(); + } + if query.view == UsageAnalyticsView::Performance { + let mut providers = BTreeMap::, Vec<&StoredRequestUsageAudit>>::new(); + let mut models = BTreeMap::>::new(); + let mut timeline = + BTreeMap::<(String, Option), Vec<&StoredRequestUsageAudit>>::new(); + let mut errors = BTreeMap::::new(); + for row in filtered { + models.entry(row.model.clone()).or_default().push(row); + let date = DateTime::::from_timestamp_millis(usage_started_ms(row) as i64) + .unwrap_or_default(); + let bucket = if query.granularity == UsageAnalyticsGranularity::Hour { + date.with_minute(0) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + .unwrap_or(date) + } else { + tz.from_local_datetime( + &date + .with_timezone(&tz) + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .earliest() + .map(|date| date.with_timezone(&Utc)) + .unwrap_or(date) + }; + timeline + .entry((bucket.to_rfc3339(), row.provider_id.clone())) + .or_default() + .push(row); + providers + .entry(row.provider_id.clone()) + .or_default() + .push(row); + if row.status == "failed" { + *errors + .entry( + metadata(row, "analytics_failure", "reason") + .or(row.error_category.as_deref()) + .unwrap_or("unknown") + .into(), + ) + .or_default() += 1; + } + } + result.provider_rows = providers + .into_iter() + .map(|(id, rows)| UsageAnalyticsRow { + label: rows.first().map(|row| row.provider_name.clone()), + id, + bucket_start: None, + metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + }) + .collect(); + result.model_rows = models + .into_iter() + .map(|(model, rows)| UsageAnalyticsRow { + label: Some(model.clone()), + id: Some(model), + bucket_start: None, + metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + }) + .collect(); + result.model_rows.sort_by(|left, right| { + right + .metrics + .request_count + .cmp(&left.metrics.request_count) + .then(left.id.cmp(&right.id)) + }); + result.errors = errors + .into_iter() + .map(|(reason, count)| UsageAnalyticsErrorCount { reason, count }) + .collect(); + result.provider_timeline_rows = timeline + .into_iter() + .map(|((bucket, id), rows)| UsageAnalyticsRow { + label: rows.first().map(|row| row.provider_name.clone()), + id, + bucket_start: Some(bucket), + metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)), + }) + .collect(); + } + Ok(result) + } + + pub(super) fn dashboard_analytics_query( + &self, + query: &UsageDashboardAnalyticsQuery, + ) -> Result { + query.validate()?; + let keys = self.analytics_key_flags(); + let rows = self + .by_request_id + .read() + .map_err(|_| DataLayerError::UnexpectedValue("usage lock poisoned".into()))?; + let users = self + .analytics_users + .read() + .map_err(|_| DataLayerError::UnexpectedValue("users lock poisoned".into()))?; + let allocations = self + .analytics_allocations + .read() + .map_err(|_| DataLayerError::UnexpectedValue("allocation lock poisoned".into()))?; + let now = Utc::now(); + let today_from = query.today_start(now)?; + let to = now.timestamp_millis().max(0) as u64; + let total_from = rows + .values() + .map(usage_started_ms) + .filter(|started| *started < to) + .min(); + let revision = format!( + "memory:{}:{}", + rows.len(), + rows.values() + .map(|row| row.updated_at_unix_secs) + .max() + .unwrap_or(0) + ); + let snapshot = |from, detailed| { + let range = UsageAnalyticsQuery { + from_unix_ms: from, + to_unix_ms: to, + timezone: query.timezone.clone(), + limit: 1, + ..Default::default() + }; + let selected = rows + .values() + .filter(|row| matches(row, &range, &keys)) + .collect::>(); + let mut summary = if detailed { + let mut summary = metrics(&selected, 5000, &keys); + apply_allocations(&mut summary, &selected, &allocations); + summary + } else { + dashboard_total_metrics(&selected, &allocations) + }; + if summary.request_count == 0 { + summary.rated_amount = Some("0.00000000".into()); + summary.billable_amount = Some("0.00000000".into()); + } + summary.enabled_users = users + .iter() + .filter(|user| user.is_active && !user.is_deleted) + .count() as u64; + StoredUsageAnalytics { + total: summary.request_count, + summary, + read_revision: revision.clone(), + generated_at: now.to_rfc3339(), + ..Default::default() + } + }; + Ok(StoredUsageDashboardAnalytics { + today: snapshot(today_from.timestamp_millis().max(0) as u64, true), + total: snapshot(total_from.unwrap_or(to), false), + today_from: today_from.to_rfc3339(), + total_from: total_from.map(timestamp), + to: now.to_rfc3339(), + history_complete: None, + }) + } + + pub(super) fn health_observations( + &self, + query: &HealthObservationQuery, + ) -> Result { + if query.from_unix_ms >= query.to_unix_ms + || query.to_unix_ms - query.from_unix_ms > 31 * 86_400_000 + || query.segments == 0 + || query.segments > 96 + { + return Err(DataLayerError::InvalidInput( + "invalid health observation window".into(), + )); + } + let rows = self + .by_request_id + .read() + .map_err(|_| DataLayerError::UnexpectedValue("usage lock poisoned".into()))?; + let candidates = self + .analytics_candidates + .read() + .map_err(|_| DataLayerError::UnexpectedValue("candidate lock poisoned".into()))?; + let width = (query.to_unix_ms - query.from_unix_ms).div_ceil(query.segments as u64); + let timeline = || { + (0..query.segments) + .filter_map(|i| { + let from = query.from_unix_ms + i as u64 * width; + (from < query.to_unix_ms).then(|| HealthObservationBucket { + from_unix_ms: from, + to_unix_ms: (from + width).min(query.to_unix_ms), + metrics: Default::default(), + }) + }) + .collect::>() + }; + let mut result = HealthObservationSummary { + timeline: timeline(), + ..Default::default() + }; + let mut objects = BTreeMap::::new(); + let mut observe = + |object: Option, time: u64, update: &dyn Fn(&mut HealthObservationMetrics)| { + let Some(value) = object.filter(|value| { + query + .object_values + .as_ref() + .is_none_or(|allowed| allowed.contains(value)) + }) else { + return; + }; + if time < query.from_unix_ms || time >= query.to_unix_ms { + return; + } + let index = ((time - query.from_unix_ms) / width) as usize; + let object = + objects + .entry(value.clone()) + .or_insert_with(|| HealthObservationObject { + object_value: value, + metrics: Default::default(), + timeline: timeline(), + }); + update(&mut result.overall); + update(&mut object.metrics); + update(&mut result.timeline[index].metrics); + update(&mut object.timeline[index].metrics); + }; + for row in rows + .values() + .filter(|row| metadata(row, "analytics_attribution", "record_kind") != Some("session")) + { + let object = match query.object_kind { + HealthObservationObjectKind::ApiFormat => row.api_format.clone(), + HealthObservationObjectKind::Model => Some(row.model.clone()), + HealthObservationObjectKind::Provider => row.provider_id.clone(), + }; + observe(object, usage_started_ms(row), &|m| { + m.request_count += 1; + m.last_request_at_unix_ms = Some( + m.last_request_at_unix_ms + .unwrap_or(0) + .max(usage_started_ms(row)), + ); + if let Some(latency) = row.response_time_ms { + m.latency_sample_count += 1; + m.latency_sum_ms += latency as f64; + } + let origin = metadata(row, "analytics_failure", "origin"); + let excluded = matches!(row.status.as_str(), "failed" | "cancelled") + && ((row.status == "cancelled" && origin == Some("client")) + || (origin == Some("client") + && (matches!( + metadata(row, "analytics_failure", "stage"), + Some("authentication" | "admission") + ) || matches!( + metadata(row, "analytics_failure", "reason"), + Some( + "invalid_input" + | "invalid_credentials" + | "quota_exceeded" + | "policy_rejection" + ) + )))); + if excluded { + m.excluded_count += 1; + } + match row.status.as_str() { + "completed" => { + m.succeeded_count += 1; + m.service_succeeded_count += 1; + } + "cancelled" | "failed" => { + if row.status == "cancelled" { + m.cancelled_count += 1; + } else { + m.failed_count += 1; + } + if !excluded { + if matches!(origin, Some("gateway" | "upstream" | "transport")) { + m.service_failed_count += 1; + } else { + m.unknown_failure_count += 1; + } + } + } + _ => m.in_progress_count += 1, + } + }); + } + for candidate in candidates + .iter() + .filter(|candidate| candidate.status.is_attempted(candidate.started_at_unix_ms)) + { + let row = rows.get(&candidate.request_id); + let object = match query.object_kind { + HealthObservationObjectKind::ApiFormat => row.and_then(|r| r.api_format.clone()), + HealthObservationObjectKind::Model => row.map(|r| r.model.clone()), + HealthObservationObjectKind::Provider => candidate.provider_id.clone(), + }; + observe( + object, + candidate.created_at_unix_ms, + &|m| match candidate.status { + RequestCandidateStatus::Success => m.attempt_succeeded_count += 1, + RequestCandidateStatus::Failed => m.attempt_failed_count += 1, + RequestCandidateStatus::Cancelled => m.attempt_cancelled_count += 1, + _ => m.attempt_in_progress_count += 1, + }, + ); + } + result.objects = objects.into_values().collect(); + Ok(result) + } +} diff --git a/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs b/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs new file mode 100644 index 000000000..28f8e2013 --- /dev/null +++ b/crates/aether-data/runtime/src/repository/usage/memory/dashboard_summary.rs @@ -0,0 +1,228 @@ +use super::{ + analytics, usage_cache_creation_tokens, usage_total_input_context, usage_total_tokens, + InMemoryUsageReadRepository, StoredRequestUsageAudit, +}; +use aether_data_contracts::{repository::usage::*, DataLayerError}; +use chrono::{DateTime, NaiveDate, Utc}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug)] +pub(super) struct DashboardProjection { + pub since: DateTime, + entries: BTreeMap, +} +impl Default for DashboardProjection { + fn default() -> Self { + Self { + since: Utc::now(), + entries: BTreeMap::new(), + } + } +} +#[derive(Debug)] +struct Contribution { + at: DateTime, + actor: Option, + metrics: DashboardSummaryMetrics, + billable_units: Option, +} +impl DashboardProjection { + pub fn record(&mut self, row: &StoredRequestUsageAudit, keys: &BTreeMap) { + let Some(at) = DateTime::from_timestamp(row.created_at_unix_ms as i64, 0) else { + return; + }; + if at < self.since { + return; + } + if row + .request_metadata + .as_ref() + .and_then(|m| m.pointer("/analytics_attribution/record_kind")) + .and_then(|v| v.as_str()) + == Some("session") + { + self.entries.remove(&row.request_id); + return; + } + let available = |key| { + row.request_metadata + .as_ref() + .and_then(|m| m.get(key)) + .and_then(|v| v.as_bool()) + != Some(false) + }; + let usage = available(USAGE_AVAILABLE_METADATA_KEY); + let priced = + available(USAGE_PRICING_AVAILABLE_METADATA_KEY) && row.billing_status == "settled"; + let stream = row + .request_metadata + .as_ref() + .and_then(|m| m.get("upstream_is_stream")) + .and_then(|v| v.as_bool()) + .unwrap_or(row.is_stream); + let metrics = DashboardSummaryMetrics { + request_count: 1, + input_tokens: if usage { row.input_tokens } else { 0 }, + output_tokens: if usage { row.output_tokens } else { 0 }, + total_tokens: if usage { + if row.total_tokens > 0 { + row.total_tokens + } else { + usage_total_tokens(row) + } + } else { + 0 + }, + usage_available_count: u64::from(usage), + pricing_available_count: u64::from(priced), + cache_read_tokens: if usage { + row.cache_read_input_tokens + } else { + 0 + }, + cache_creation_tokens: if usage { + usage_cache_creation_tokens(row) + } else { + 0 + }, + cache_input_tokens: if usage { + usage_total_input_context(row) + } else { + 0 + }, + first_byte_sum_ms: row.first_byte_time_ms.unwrap_or(0) as f64, + first_byte_sample_count: u64::from(row.first_byte_time_ms.is_some()), + response_sum_ms: row.response_time_ms.unwrap_or(0) as f64, + response_sample_count: u64::from(row.response_time_ms.is_some()), + stream_requests: u64::from(stream), + standard_requests: u64::from(!stream), + ..Default::default() + }; + self.entries.insert( + row.request_id.clone(), + Contribution { + at, + actor: analytics::actor(row, keys).map(str::to_owned), + metrics, + billable_units: priced + .then(|| (row.actual_total_cost_usd * 100_000_000.0).round() as i128), + }, + ); + } +} +fn sum_metrics<'a>(rows: impl Iterator) -> DashboardSummaryMetrics { + let mut sum = DashboardSummaryMetrics::default(); + let mut users = BTreeSet::new(); + let mut units = 0_i128; + for row in rows { + let m = &row.metrics; + sum.request_count += m.request_count; + sum.input_tokens += m.input_tokens; + sum.output_tokens += m.output_tokens; + sum.total_tokens += m.total_tokens; + sum.usage_available_count += m.usage_available_count; + sum.pricing_available_count += m.pricing_available_count; + sum.cache_read_tokens += m.cache_read_tokens; + sum.cache_creation_tokens += m.cache_creation_tokens; + sum.cache_input_tokens += m.cache_input_tokens; + sum.first_byte_sum_ms += m.first_byte_sum_ms; + sum.first_byte_sample_count += m.first_byte_sample_count; + sum.response_sum_ms += m.response_sum_ms; + sum.response_sample_count += m.response_sample_count; + sum.stream_requests += m.stream_requests; + sum.standard_requests += m.standard_requests; + units += row.billable_units.unwrap_or(0); + if let Some(actor) = row.actor.as_deref() { + users.insert(actor); + } + } + sum.active_users = users.len() as u64; + if sum.request_count == 0 || sum.pricing_available_count > 0 { + sum.billable_amount = Some(format!( + "{}{}.{:08}", + if units < 0 { "-" } else { "" }, + units.abs() / 100_000_000, + units.abs() % 100_000_000 + )); + } + sum +} +impl InMemoryUsageReadRepository { + /// Test/embedded initialization boundary; seeded older audit rows stay excluded. + pub fn with_dashboard_stats_since(self, since: DateTime) -> Self { + let keys = self.analytics_key_flags(); + let mut projection = self + .dashboard_projection + .write() + .expect("dashboard projection lock"); + projection.since = since; + projection.entries.clear(); + for row in self + .by_request_id + .read() + .expect("usage repository lock") + .values() + { + projection.record(row, &keys); + } + drop(projection); + self + } + pub(super) fn dashboard_summary_query( + &self, + query: &UsageDashboardAnalyticsQuery, + ) -> Result { + query.validate()?; + let projection = self.dashboard_projection.read().map_err(|_| { + DataLayerError::UnexpectedValue("dashboard projection lock poisoned".into()) + })?; + let now = Utc::now(); + let today_from = query.today_start(now)?.max(projection.since); + let tz = query + .timezone + .parse::() + .map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?; + let rows = || projection.entries.values().filter(|row| row.at < now); + let today = sum_metrics(rows().filter(|row| row.at >= today_from)); + let total = sum_metrics(rows()); + let mut days = BTreeMap::::new(); + for row in rows() { + *days + .entry(row.at.with_timezone(&tz).date_naive()) + .or_default() += 1; + } + let active_days = days.len() as u64; + let local_today = now.with_timezone(&tz).date_naive(); + let consecutive_active_days = + dashboard_consecutive_active_days(days.keys().copied(), local_today); + let earliest_day = local_today - chrono::Duration::days(364); + let activity_days = days + .into_iter() + .filter(|(day, _)| day >= &earliest_day) + .map(|(date, requests)| DashboardActivityDay { + date: date.to_string(), + requests, + }) + .collect(); + let users = self + .analytics_users + .read() + .map_err(|_| DataLayerError::UnexpectedValue("users lock poisoned".into()))?; + Ok(StoredDashboardSummary { + stats_since: projection.since.to_rfc3339(), + generated_at: now.to_rfc3339(), + timezone: query.timezone.clone(), + today_from: today_from.to_rfc3339(), + window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0, + today, + total, + users: DashboardUserCounts { + total: users.iter().filter(|user| !user.is_deleted).count() as u64, + ..Default::default() + }, + active_days, + consecutive_active_days, + activity_days, + }) + } +} diff --git a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs index 17bd0a7e9..c0e1ac629 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs @@ -22,6 +22,486 @@ use aether_data_contracts::repository::usage::{ }; use serde_json::json; +#[tokio::test] +async fn overview_model_performance_merges_provider_samples_without_pagination() { + use aether_data_contracts::repository::usage::*; + let at = chrono::DateTime::parse_from_rfc3339("2026-09-12T10:05:00Z").unwrap(); + let mut records = Vec::new(); + for (index, provider, first_byte, response_time, output_tokens) in [ + (0, "provider-a", 100, 1100, 100), + (1, "provider-a", 300, 1300, 200), + (2, "provider-b", 500, 2500, 400), + ] { + let mut row = sample_usage(&format!("model-sample-{index}"), at.timestamp()); + row.provider_id = Some(provider.into()); + row.model = "shared-model".into(); + row.target_model = Some(format!("{provider}-deployment")); + row.first_byte_time_ms = Some(first_byte); + row.response_time_ms = Some(response_time); + row.output_tokens = output_tokens; + row.is_stream = true; + records.push(row); + } + let mut failed = sample_usage("model-failed", at.timestamp()); + failed.model = "shared-model".into(); + failed.status = "failed".into(); + failed.first_byte_time_ms = None; + failed.response_time_ms = None; + records.push(failed); + let mut pending = sample_usage("model-pending", at.timestamp()); + pending.model = "pending-model".into(); + pending.status = "pending".into(); + pending.first_byte_time_ms = None; + pending.response_time_ms = None; + records.push(pending); + + let repo = InMemoryUsageReadRepository::seed(records); + let query = UsageAnalyticsQuery { + from_unix_ms: (at - chrono::Duration::minutes(5)).timestamp_millis() as u64, + to_unix_ms: (at + chrono::Duration::minutes(55)).timestamp_millis() as u64, + timezone: "UTC".into(), + view: UsageAnalyticsView::Performance, + limit: 1, + offset: 1, + ..Default::default() + }; + let result = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(result.model_rows.len(), 2); + assert_eq!(result.model_rows[0].id.as_deref(), Some("shared-model")); + assert!(result + .model_rows + .iter() + .all(|row| row.bucket_start.is_none())); + let metrics = &result.model_rows[0].metrics; + assert_eq!(metrics.request_count, 4); + assert_eq!(metrics.successful_request_count, 3); + assert_eq!(metrics.failed_request_count, 1); + assert_eq!(metrics.first_byte_sample_count, 3); + assert_eq!(metrics.first_byte_sum_ms, 900.0); + assert_eq!(metrics.latency_sample_count, 3); + assert_eq!(metrics.latency_sum_ms, 4900.0); + assert_eq!(metrics.output_tps_sample_count, 3); + assert_eq!(metrics.output_tps_sum, 500.0); + assert_eq!(result.model_rows[1].metrics.first_byte_sample_count, 0); + assert_eq!(result.model_rows[1].metrics.in_flight_request_count, 1); + assert_eq!( + result + .model_rows + .iter() + .map(|row| row.metrics.request_count) + .sum::(), + result.summary.request_count + ); + + let filtered = repo + .query_usage_analytics(&UsageAnalyticsQuery { + model: Some("shared-model".into()), + ..query + }) + .await + .unwrap(); + assert_eq!(filtered.model_rows, vec![result.model_rows[0].clone()]); +} + +#[tokio::test] +async fn overview_memory_chart_hour_buckets_are_utc_in_half_hour_zones() { + use aether_data_contracts::repository::usage::*; + let at = chrono::DateTime::parse_from_rfc3339("2026-09-12T10:05:00Z").unwrap(); + let repo = InMemoryUsageReadRepository::seed([sample_usage("hour-zone", at.timestamp())]); + let query = UsageAnalyticsQuery { + from_unix_ms: (at - chrono::Duration::minutes(5)).timestamp_millis() as u64, + to_unix_ms: (at + chrono::Duration::minutes(55)).timestamp_millis() as u64, + timezone: "Asia/Kolkata".into(), + view: UsageAnalyticsView::DashboardCharts, + granularity: UsageAnalyticsGranularity::Hour, + limit: 1, + ..Default::default() + }; + let charts = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(charts.summary.request_count, 1); + assert_eq!(charts.rows.len(), 1); + assert_eq!(charts.rows[0].metrics.request_count, 1); + assert_eq!( + charts.rows[0].bucket_start, + charts.model_rows[0].bucket_start + ); +} + +#[tokio::test] +async fn overview_memory_chart_days_survive_skipped_midnight() { + use aether_data_contracts::repository::usage::*; + let moments = [ + "2026-09-05T12:00:00Z", + "2026-09-06T12:00:00Z", + "2026-09-07T12:00:00Z", + ]; + let repo = InMemoryUsageReadRepository::seed(moments.map(|moment| { + sample_usage( + moment, + chrono::DateTime::parse_from_rfc3339(moment) + .unwrap() + .timestamp(), + ) + })); + let query = UsageAnalyticsQuery { + from_unix_ms: chrono::DateTime::parse_from_rfc3339("2026-09-05T04:00:00Z") + .unwrap() + .timestamp_millis() as u64, + to_unix_ms: chrono::DateTime::parse_from_rfc3339("2026-09-08T03:00:00Z") + .unwrap() + .timestamp_millis() as u64, + timezone: "America/Santiago".into(), + view: UsageAnalyticsView::DashboardCharts, + limit: 1, + ..Default::default() + }; + let charts = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(charts.summary.request_count, 3); + assert_eq!(charts.rows.len(), 3); + assert_eq!(charts.model_rows.len(), 3); + for (series, model) in charts.rows.iter().zip(&charts.model_rows) { + assert_eq!(series.metrics.request_count, 1); + assert_eq!(series.bucket_start, model.bucket_start); + assert_eq!( + series.metrics.billable_amount, + model.metrics.billable_amount + ); + } +} + +#[tokio::test] +async fn overview_memory_dashboard_keeps_all_history_and_chart_dimensions() { + use aether_data_contracts::repository::usage::*; + let now = chrono::Utc::now(); + let mut old = sample_usage( + "old-dashboard", + (now - chrono::Duration::days(800)).timestamp(), + ); + old.actual_total_cost_usd = 2.0; + old.model = "old-model".into(); + let mut current = sample_usage("current-dashboard", now.timestamp()); + current.actual_total_cost_usd = 0.5; + current.model = "new-model".into(); + let repo = InMemoryUsageReadRepository::seed([old, current]); + let dashboard = repo + .query_dashboard_analytics(&UsageDashboardAnalyticsQuery { + timezone: "Asia/Shanghai".into(), + }) + .await + .unwrap(); + assert_eq!(dashboard.today.summary.request_count, 1); + assert_eq!(dashboard.total.summary.request_count, 2); + assert_eq!( + dashboard.today.summary.billable_amount.as_deref(), + Some("0.50000000") + ); + assert_eq!( + dashboard.total.summary.billable_amount.as_deref(), + Some("2.50000000") + ); + assert_eq!(dashboard.today.read_revision, dashboard.total.read_revision); + assert_eq!(dashboard.history_complete, None); + let charts = repo + .query_usage_analytics(&UsageAnalyticsQuery { + from_unix_ms: (now - chrono::Duration::days(1)).timestamp_millis() as u64, + to_unix_ms: (now + chrono::Duration::seconds(1)).timestamp_millis() as u64, + timezone: "Asia/Shanghai".into(), + view: UsageAnalyticsView::DashboardCharts, + limit: 1, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(charts.model_rows.len(), 1); + assert_eq!(charts.model_rows[0].id.as_deref(), Some("new-model")); + assert_eq!(charts.provider_rows.len(), 1); + assert_eq!( + charts.model_rows[0].metrics.billable_amount, + charts.summary.billable_amount + ); +} + +#[tokio::test] +async fn overview_memory_dashboard_total_keeps_card_coverage_without_historical_diagnostics() { + use aether_data_contracts::repository::usage::*; + let now = chrono::Utc::now(); + let mut known = sample_usage("dashboard-total-known", now.timestamp()); + known.request_metadata = Some(json!({"analytics_attribution":{"is_standalone":false}})); + let mut missing = sample_usage( + "dashboard-total-missing", + (now - chrono::Duration::days(800)).timestamp(), + ); + missing.billing_status = "pending".into(); + missing.request_metadata = + Some(json!({"usage_available":false,"usage_pricing_available":false})); + let mut session = sample_usage("dashboard-total-session", now.timestamp()); + session.request_metadata = Some(json!({"analytics_attribution":{"record_kind":"session"}})); + let future = sample_usage( + "dashboard-total-future", + (now + chrono::Duration::days(1)).timestamp(), + ); + let repo = InMemoryUsageReadRepository::seed([known, missing, session, future]) + .with_analytics_allocations([UsageAnalyticsAllocation { + request_id: "dashboard-total-known".into(), + complete: true, + wallet_debit_amount: Some("0.18000000".into()), + ..Default::default() + }]); + let dashboard = repo + .query_dashboard_analytics(&UsageDashboardAnalyticsQuery { + timezone: "UTC".into(), + }) + .await + .unwrap(); + let total = dashboard.total.summary; + assert_eq!(total.request_count, 2); + assert_eq!(total.total_tokens, 150); + assert_eq!(total.billable_amount.as_deref(), Some("0.18000000")); + assert_eq!(total.usage_available_count, 1); + assert_eq!(total.pricing_available_count, 1); + assert_eq!(total.settled_count, 1); + assert_eq!(total.allocation_available_count, 1); + assert!(total.latency_p95_ms.is_none()); + assert!(total.wallet_debit_amount.is_none()); + assert_eq!(dashboard.today.summary.latency_p95_ms, Some(420.0)); + assert_eq!(dashboard.today.summary.successful_request_count, 1); + assert_eq!( + dashboard.today.summary.wallet_debit_amount.as_deref(), + Some("0.18000000") + ); +} + +#[tokio::test] +async fn overview_memory_employee_roster_and_allocations_are_global() { + use aether_data_contracts::repository::usage::*; + use aether_data_contracts::repository::users::StoredUserSummary; + let mut usage = sample_usage("overview-request", 1_700_000_000); + usage.user_id = Some("alice".into()); + usage.request_metadata = Some(json!({"analytics_attribution":{"is_standalone":false}})); + usage.billing_status = "settled".into(); + usage.actual_total_cost_usd = 0.00000001; + let repo = InMemoryUsageReadRepository::seed([usage]) + .with_analytics_users([ + StoredUserSummary::new( + "alice".into(), + "Alice".into(), + None, + "user".into(), + true, + false, + ) + .unwrap(), + StoredUserSummary::new( + "zero".into(), + "Zero".into(), + None, + "user".into(), + true, + false, + ) + .unwrap(), + ]) + .with_analytics_allocations([UsageAnalyticsAllocation { + request_id: "overview-request".into(), + quota_covered_amount: Some("0.00000000".into()), + wallet_consumed_amount: Some("0.00000001".into()), + wallet_debit_amount: Some("0.00000000".into()), + complete: true, + ..Default::default() + }]); + let mut query = UsageAnalyticsQuery { + from_unix_ms: 1_700_000_000_000, + to_unix_ms: 1_700_000_060_000, + timezone: "UTC".into(), + view: UsageAnalyticsView::Users, + limit: 1, + descending: true, + ..Default::default() + }; + let first = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(first.total, 2); + assert_eq!(first.users[0].user_id, "alice"); + assert_eq!(first.user_summary.as_ref().unwrap().user_count, 2); + assert_eq!(first.user_summary.as_ref().unwrap().active_user_count, 1); + assert!(first.user_finance_summary.is_none()); + assert!(first.user_payments.is_none()); + assert!(first.users[0].finance.is_none()); + assert_eq!( + first.summary.wallet_consumed_amount.as_deref(), + Some("0.00000001") + ); + assert_eq!( + first.summary.wallet_debit_amount.as_deref(), + Some("0.00000000") + ); + query.offset = 1; + let second = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(second.users[0].user_id, "zero"); + assert_eq!(second.users[0].metrics.request_count, 0); + assert_eq!(second.user_summary, first.user_summary); + let searched = repo + .query_usage_analytics(&UsageAnalyticsQuery { + search: Some("Zero".into()), + offset: 0, + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(searched.user_summary.as_ref().unwrap().user_count, 1); + assert_eq!(searched.user_summary.as_ref().unwrap().active_user_count, 0); + assert_eq!(searched.summary.request_count, 0); + query.from_unix_ms = query.to_unix_ms; + query.to_unix_ms += 60_000; + let outside = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(outside.summary.request_count, 0); +} + +#[tokio::test] +async fn overview_memory_employee_is_grouped_by_account_owner() { + use aether_data_contracts::repository::{usage::*, users::StoredUserSummary}; + let mut usage = sample_usage("trusted-request", 1_700_000_000); + usage.user_id = Some("owner".into()); + usage.request_metadata = + Some(json!({"analytics_attribution":{"is_standalone":false,"actor_user_id":"actor"}})); + let repo = InMemoryUsageReadRepository::seed([usage]).with_analytics_users( + ["owner", "actor"].map(|id| { + StoredUserSummary::new(id.into(), id.into(), None, "user".into(), true, false).unwrap() + }), + ); + let query = UsageAnalyticsQuery { + from_unix_ms: 1_700_000_000_000, + to_unix_ms: 1_700_000_060_000, + timezone: "UTC".into(), + view: UsageAnalyticsView::Users, + attribution_kind: Some("employee".into()), + has_usage: Some(true), + limit: 100, + ..Default::default() + }; + let result = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(result.total, 1); + assert_eq!(result.users[0].user_id, "owner"); + let detail = repo + .query_usage_analytics(&UsageAnalyticsQuery { + actor_user_id: Some("owner".into()), + ..query + }) + .await + .unwrap(); + assert_eq!(detail.users[0], result.users[0]); +} + +#[tokio::test] +async fn overview_memory_legacy_requests_use_existing_key_account_flags() { + use aether_data_contracts::repository::{usage::*, users::StoredUserSummary}; + let snapshots = [("member-key", false), ("standalone-key", true)].map(|(id, standalone)| { + ( + None, + StoredAuthApiKeySnapshot::new( + "user-1".into(), + "alice".into(), + None, + "user".into(), + "local".into(), + true, + false, + None, + None, + None, + id.into(), + None, + true, + false, + standalone, + None, + None, + None, + None, + None, + None, + ) + .unwrap(), + ) + }); + let keys = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(snapshots)); + let rows = ["member-key", "standalone-key", "deleted-key"].map(|id| { + let mut usage = sample_usage(id, 1_700_000_000); + usage.api_key_id = Some(id.into()); + usage.request_metadata = None; + usage + }); + let repo = InMemoryUsageReadRepository::seed(rows) + .with_auth_api_key_repository(keys) + .with_analytics_users([StoredUserSummary::new( + "user-1".into(), + "alice".into(), + None, + "user".into(), + true, + false, + ) + .unwrap()]); + let mut query = UsageAnalyticsQuery { + from_unix_ms: 1_700_000_000_000, + to_unix_ms: 1_700_000_060_000, + timezone: "UTC".into(), + view: UsageAnalyticsView::Consumption, + limit: 100, + ..Default::default() + }; + let result = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(result.summary.request_count, 3); + assert_eq!(result.summary.usage_active_users, 1); + assert_eq!(result.summary.trusted_attribution_count, 1); + for (key, kind, source, user) in [ + ("member-key", "employee", "user_account", Some("user-1")), + ("standalone-key", "standalone", "standalone_key", None), + ("deleted-key", "unknown", "unknown", None), + ] { + let row = result + .consumption + .iter() + .find(|row| row.request_id == key) + .unwrap(); + assert_eq!(row.attribution_kind, kind); + assert_eq!(row.attribution_source, source); + assert_eq!(row.user_id.as_deref(), user); + assert_eq!(row.credential_owner_id.as_deref(), Some("user-1")); + } + query.view = UsageAnalyticsView::Users; + query.attribution_kind = Some("employee".into()); + let employees = repo.query_usage_analytics(&query).await.unwrap(); + assert_eq!(employees.users[0].metrics.request_count, 1); + assert_eq!(employees.summary.request_count, 1); +} + +#[tokio::test] +async fn overview_memory_health_does_not_treat_upstream_cancellation_as_client() { + use aether_data_contracts::repository::usage::*; + let mut upstream = sample_usage("upstream-cancel", 1_700_000_000); + upstream.status = "cancelled".into(); + upstream.request_metadata = + Some(json!({"analytics_failure":{"origin":"upstream","reason":"provider_cancelled"}})); + let mut client = upstream.clone(); + client.request_id = "client-cancel".into(); + client.request_metadata = + Some(json!({"analytics_failure":{"origin":"client","reason":"downstream_disconnect"}})); + let repo = InMemoryUsageReadRepository::seed([upstream, client]); + let summary = repo + .summarize_health_observations(&HealthObservationQuery { + from_unix_ms: 1_700_000_000_000, + to_unix_ms: 1_700_000_060_000, + object_kind: HealthObservationObjectKind::Model, + object_values: None, + segments: 4, + }) + .await + .unwrap(); + assert_eq!(summary.overall.request_count, 2); + assert_eq!(summary.overall.service_failed_count, 1); + assert_eq!(summary.overall.excluded_count, 1); +} + fn sample_usage(request_id: &str, created_at_unix_ms: i64) -> StoredRequestUsageAudit { StoredRequestUsageAudit::new( "usage-1".to_string(), @@ -2763,3 +3243,99 @@ async fn summarize_usage_provider_performance_computes_tps_and_top_provider_time assert_eq!(without_timeline.providers, summary.providers); assert!(without_timeline.timeline.is_empty()); } + +#[tokio::test] +async fn future_dashboard_summary_excludes_old_rows_and_preserves_canonical_cache_samples() { + use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery; + let now = chrono::Utc::now(); + let since = now - chrono::Duration::minutes(5); + let mut included = sample_usage( + "future-summary", + (now - chrono::Duration::seconds(1)).timestamp(), + ); + included.api_key_id = None; + included.api_format = Some("claude:messages".into()); + included.endpoint_api_format = Some("claude:messages".into()); + included.input_tokens = 80; + included.output_tokens = 20; + included.total_tokens = 150; + included.cache_read_input_tokens = 40; + included.cache_creation_input_tokens = 10; + included.response_time_ms = Some(800); + included.first_byte_time_ms = Some(100); + included.is_stream = false; + included.request_metadata = Some(json!({"upstream_is_stream": true})); + included.actual_total_cost_usd = 0.12345678; + included.billing_status = "settled".into(); + let mut excluded = included.clone(); + excluded.request_id = "before-activation".into(); + excluded.created_at_unix_ms = (since - chrono::Duration::days(500)).timestamp() as u64; + let repo = + InMemoryUsageReadRepository::seed([included, excluded]).with_dashboard_stats_since(since); + let first = repo + .query_dashboard_summary(&UsageDashboardAnalyticsQuery { + timezone: "Asia/Kathmandu".into(), + }) + .await + .unwrap(); + assert_eq!(first.total.request_count, 1); + assert_eq!(first.total.total_tokens, 150); + assert_eq!(first.total.billable_amount.as_deref(), Some("0.12345678")); + assert_eq!(first.today.cache_input_tokens, 130); + assert_eq!(first.today.cache_read_tokens, 40); + assert_eq!(first.today.first_byte_sample_count, 1); + assert_eq!(first.today.response_sample_count, 1); + assert_eq!(first.today.stream_requests, 1); + assert_eq!(first.today.standard_requests, 0); + assert_eq!(first.active_days, 1); + assert_eq!(first.consecutive_active_days, 1); + assert_eq!( + first.activity_days.iter().map(|d| d.requests).sum::(), + 1 + ); + // Audit retention does not own the additive projection. + repo.by_request_id.write().unwrap().clear(); + let retained = repo + .query_dashboard_summary(&UsageDashboardAnalyticsQuery { + timezone: "Asia/Kathmandu".into(), + }) + .await + .unwrap(); + assert_eq!(retained.total, first.total); + assert_eq!(retained.stats_since, since.to_rfc3339()); +} + +#[tokio::test] +async fn future_dashboard_summary_streak_uses_local_days_before_heatmap_truncation() { + use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery; + use chrono::TimeZone; + + let now = chrono::Utc::now(); + let tz = chrono_tz::Asia::Kathmandu; + let today = now.with_timezone(&tz).date_naive(); + let rows = (1..=400).map(|offset| { + let day = today - chrono::Duration::days(offset); + let at = tz + .from_local_datetime(&day.and_hms_opt(12, 0, 0).unwrap()) + .single() + .unwrap(); + sample_usage(&format!("streak-{offset}"), at.timestamp()) + }); + let repo = InMemoryUsageReadRepository::seed(rows) + .with_dashboard_stats_since(now - chrono::Duration::days(401)); + let summary = repo + .query_dashboard_summary(&UsageDashboardAnalyticsQuery { + timezone: tz.to_string(), + }) + .await + .unwrap(); + + assert_eq!(summary.today.request_count, 0); + assert_eq!(summary.active_days, 400); + assert_eq!(summary.consecutive_active_days, 400); + assert_eq!(summary.activity_days.len(), 364); + assert_eq!( + summary.activity_days.last().unwrap().date, + today.pred_opt().unwrap().to_string() + ); +} diff --git a/crates/aether-data/runtime/src/repository/wallet/memory.rs b/crates/aether-data/runtime/src/repository/wallet/memory.rs index 08df3efc8..fb2287ce4 100644 --- a/crates/aether-data/runtime/src/repository/wallet/memory.rs +++ b/crates/aether-data/runtime/src/repository/wallet/memory.rs @@ -831,6 +831,12 @@ impl WalletReadRepository for InMemoryWalletRepository { .as_deref() .is_none_or(|expected| wallet.status == expected) }) + .filter(|wallet| { + query + .user_id + .as_deref() + .is_none_or(|expected| wallet.user_id.as_deref() == Some(expected)) + }) .filter(|wallet| match query.owner_type.as_deref() { Some("user") => wallet.user_id.is_some(), Some("api_key") => wallet.api_key_id.is_some(), @@ -3418,6 +3424,7 @@ mod tests { let page = repository .list_admin_wallets(&AdminWalletListQuery { + user_id: None, status: Some("active".to_string()), owner_type: Some("api_key".to_string()), limit: 1, @@ -3430,6 +3437,31 @@ mod tests { assert_eq!(page.items.len(), 1); assert_eq!(page.items[0].id, "wallet-3"); assert_eq!(page.items[0].updated_at_unix_secs, Some(110)); + + let query = AdminWalletListQuery { + user_id: Some("user-2".to_string()), + owner_type: Some("user".to_string()), + limit: 1, + ..Default::default() + }; + let selected = repository.list_admin_wallets(&query).await.unwrap(); + assert_eq!(selected.total, 1); + assert_eq!(selected.items[0].id, "wallet-2"); + let missing = repository + .list_admin_wallets(&AdminWalletListQuery { + user_id: Some("missing-user".to_string()), + ..query.clone() + }) + .await + .unwrap(); + assert_eq!(missing.total, 0); + assert!(missing.items.is_empty()); + let beyond_page = repository + .list_admin_wallets(&AdminWalletListQuery { offset: 1, ..query }) + .await + .unwrap(); + assert_eq!(beyond_page.total, 1); + assert!(beyond_page.items.is_empty()); } #[tokio::test] diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index 0ec9e393e..72fbaaede 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -125,6 +125,7 @@ pub enum UsageTerminalState { #[derive(Debug, Clone)] pub struct TerminalUsageContextSeed { + token_measurement_source: &'static str, pub client_contract: String, pub provider_contract: String, pub request_id: String, @@ -190,6 +191,7 @@ pub struct StreamTerminalUsagePayloadSeed { #[derive(Debug, Clone)] pub struct TerminalUsageSeed { + token_measurement_source: &'static str, pub terminal_state: UsageTerminalState, pub client_contract: String, pub provider_contract: String, @@ -650,6 +652,7 @@ fn build_terminal_usage_event_from_seed_impl( trusted_request_metadata: bool, ) -> Result { let TerminalUsageSeed { + token_measurement_source, terminal_state, client_contract, provider_contract, @@ -785,7 +788,7 @@ fn build_terminal_usage_event_from_seed_impl( }; if let Some(usage) = standardized_usage.as_ref() { - apply_standardized_usage_seed(usage, &mut data); + apply_standardized_usage_seed(usage, &mut data, token_measurement_source); } if data.total_tokens.is_none() { @@ -795,6 +798,7 @@ fn build_terminal_usage_event_from_seed_impl( .and_then(extract_token_counts_from_value) { data.input_tokens = Some(tokens.0); + mark_analytics_measurement(&mut data, token_measurement_source); data.output_tokens = Some(tokens.1); data.total_tokens = Some(tokens.2); } @@ -840,6 +844,13 @@ pub fn build_terminal_usage_context_seed( ); TerminalUsageContextSeed { + token_measurement_source: match context_value_ref(context, "usage_token_source") + .and_then(Value::as_str) + { + Some("estimated") => "estimated", + Some("mixed") => "mixed", + _ => "reported", + }, client_contract, provider_contract, has_format_conversion, @@ -1036,6 +1047,21 @@ pub fn build_sync_terminal_usage_seed( let derived_standardized_usage = provider_response_full .as_ref() .map(|response| map_usage_from_response(response, context_seed.provider_contract.as_str())); + // Context usage here is Kiro's locally simulated input/cache usage. A + // standard response may still contribute independently reported output. + let token_measurement_source = + if standardized_usage.is_some() && context_seed.token_measurement_source != "estimated" { + if derived_standardized_usage + .as_ref() + .is_some_and(|usage| usage.output_tokens > 0 || usage.reasoning_tokens > 0) + { + "mixed" + } else { + "estimated" + } + } else { + context_seed.token_measurement_source + }; let standardized_usage = merge_standardized_usage_with_context_cache(standardized_usage, derived_standardized_usage); let terminal_state = infer_sync_terminal_state( @@ -1049,6 +1075,7 @@ pub fn build_sync_terminal_usage_seed( ); TerminalUsageSeed { + token_measurement_source, terminal_state, client_contract: context_seed.client_contract, provider_contract: context_seed.provider_contract, @@ -1233,6 +1260,7 @@ pub fn build_stream_terminal_usage_seed( ); TerminalUsageSeed { + token_measurement_source: context_seed.token_measurement_source, terminal_state, client_contract: context_seed.client_contract, provider_contract: context_seed.provider_contract, @@ -2158,6 +2186,11 @@ fn build_runtime_request_metadata_seed_from_parts( provider_request_body_base64: Option<&str>, ) -> Option { let mut metadata = Map::new(); + for key in ["analytics_attribution", "analytics_failure"] { + if let Some(value) = context_value_ref(context, key) { + metadata.insert(key.into(), value.clone()); + } + } if let Some(trace_id) = context_string(context, "trace_id") { metadata.insert("trace_id".to_string(), Value::String(trace_id)); } @@ -2630,7 +2663,30 @@ fn infer_endpoint_kind(api_format: &str) -> Option<&str> { api_format.split_once(':').map(|(_, kind)| kind) } -fn apply_standardized_usage_seed(usage: &StandardizedUsage, data: &mut UsageEventData) { +fn apply_standardized_usage_seed( + usage: &StandardizedUsage, + data: &mut UsageEventData, + token_source: &str, +) { + let token_source = usage + .token_source + .map(|source| source.as_str()) + .unwrap_or(token_source); + // Dimensions such as image_count are not evidence of measured tokens. + if [ + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_tokens, + usage.cache_creation_ephemeral_5m_tokens, + usage.cache_creation_ephemeral_1h_tokens, + usage.cache_read_tokens, + usage.reasoning_tokens, + ] + .into_iter() + .any(|tokens| tokens > 0) + { + mark_analytics_measurement(data, token_source); + } if usage.input_tokens > 0 { data.input_tokens = Some(usage.input_tokens as u64); } @@ -3117,6 +3173,7 @@ fn apply_completed_image_usage_estimate(data: &mut UsageEventData) { if positive_tokens(data.input_tokens) == 0 { if let Some(usage) = request_usage.as_ref() { data.input_tokens = Some(usage.input_tokens); + mark_analytics_measurement(data, "estimated"); } } apply_request_cache_usage_estimate(data, request_usage.as_ref()); @@ -3129,6 +3186,25 @@ fn apply_completed_image_usage_estimate(data: &mut UsageEventData) { } } +fn mark_analytics_measurement(data: &mut UsageEventData, source: &str) { + let mut metadata = data + .request_metadata + .take() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + let previous = metadata + .get("analytics_measurement") + .and_then(|value| value.get("source")) + .and_then(Value::as_str); + let source = match previous { + Some("mixed") => "mixed", + Some(old) if old != source && old != "unknown" => "mixed", + _ => source, + }; + metadata.insert("analytics_measurement".into(), json!({"source":source})); + data.request_metadata = Some(Value::Object(metadata)); +} + fn apply_completed_image_dimensions(data: &mut UsageEventData) { let image_count = usage_dimension_i64(data.request_metadata.as_ref(), "image_count") .or_else(|| image_response_count(data.response_body.as_ref())) @@ -5202,6 +5278,10 @@ mod tests { assert!(event.data.input_tokens.unwrap_or_default() > 0); assert_eq!(event.data.output_tokens.unwrap_or_default(), 0); assert_eq!(event.data.total_tokens, event.data.input_tokens); + assert_eq!( + event.data.request_metadata.as_ref().unwrap()["analytics_measurement"]["source"], + "estimated" + ); assert_eq!( event .data @@ -5648,6 +5728,44 @@ mod tests { assert_eq!(event.data.input_tokens, Some(4)); assert_eq!(event.data.output_tokens, Some(6)); assert_eq!(event.data.total_tokens, Some(10)); + assert_eq!( + event.data.request_metadata.as_ref().unwrap()["analytics_measurement"]["source"], + "reported" + ); + for source in ["estimated", "mixed"] { + let mut payload = payload.clone(); + payload.report_context.as_mut().unwrap()["usage_token_source"] = json!(source); + let event = + build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload) + .unwrap(); + assert_eq!(event.data.input_tokens, Some(4)); + assert_eq!(event.data.output_tokens, Some(6)); + assert_eq!(event.data.total_tokens, Some(10)); + assert_eq!( + event.data.request_metadata.as_ref().unwrap()["analytics_measurement"]["source"], + source + ); + assert!(event + .data + .request_metadata + .as_ref() + .unwrap() + .get("usage_token_source") + .is_none()); + + payload.status_code = 500; + payload.body_json = Some(json!({"error": {"message": "no token measurement"}})); + let failed = + build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload) + .unwrap(); + assert!(failed + .data + .request_metadata + .as_ref() + .unwrap() + .get("analytics_measurement") + .is_none()); + } assert_eq!( event.data.response_headers, Some(json!({ @@ -6484,6 +6602,32 @@ mod tests { assert_eq!(event.data.cache_creation_input_tokens, Some(1200)); assert_eq!(event.data.cache_read_input_tokens, Some(300)); assert_eq!(event.data.total_tokens, Some(300)); + assert_eq!( + event.data.request_metadata.as_ref().unwrap()["analytics_measurement"]["source"], + "estimated" + ); + + let mut payload = payload; + payload.body_json = Some(json!({"usage": {"output_tokens": 17}})); + let mixed = + build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload) + .unwrap(); + assert_eq!(mixed.data.output_tokens, Some(17)); + assert_eq!(mixed.data.cache_read_input_tokens, Some(300)); + assert_eq!( + mixed.data.request_metadata.as_ref().unwrap()["analytics_measurement"]["source"], + "mixed" + ); + + payload.report_context.as_mut().unwrap()["usage_token_source"] = json!("estimated"); + let native = + build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload) + .unwrap(); + assert_eq!(native.data.output_tokens, Some(17)); + assert_eq!( + native.data.request_metadata.as_ref().unwrap()["analytics_measurement"]["source"], + "estimated" + ); } #[test] @@ -6553,6 +6697,7 @@ mod tests { #[test] fn manual_terminal_seed_event_builder_sanitizes_metadata_but_preserves_headers_and_bodies() { let event = build_terminal_usage_event_from_seed(TerminalUsageSeed { + token_measurement_source: "reported", terminal_state: UsageTerminalState::Completed, client_contract: "openai:chat".to_string(), provider_contract: "openai:chat".to_string(), diff --git a/docs/WebSocket-Mode.md b/docs/WebSocket-Mode.md deleted file mode 100644 index 1f6ca6fcc..000000000 --- a/docs/WebSocket-Mode.md +++ /dev/null @@ -1,428 +0,0 @@ -# WebSocket transports - -Aether exposes several independent WebSocket surfaces. They share transport -machinery, but not request schemas or continuation state: - -| Public route | API format | Protocol | -| --- | --- | --- | -| `GET /v1/responses` | `openai:responses` | Responses WebSocket mode; every turn starts with `response.create`. | -| `GET /v1/realtime?model=...` | `openai:realtime` | OpenAI Realtime JSON events, including Base64 audio events. | -| `GET /v1/realtime?model=...` with a first-party Codex `originator` | `codex:live` | Current Codex Realtime v2 direct WebSocket transport. | -| `POST /v1/realtime/calls`, `GET /v1/realtime?intent=quicksilver&call_id=...` | `codex:live` | Codex Realtime v1 AVAS WebRTC call creation and sideband transport. | -| `GET/POST /v1/live[/{call_id}]` | `codex:live` | Legacy Codex Frameless direct and WebRTC compatibility transport. | - -Do not point one surface at an endpoint configured for another. In particular, -a Realtime or Live event is not passed through the Responses -`response.create` state machine. - -## Responses WebSocket mode - -The Responses API supports a WebSocket mode for long-running, tool-call-heavy workflows. In this mode, you keep a persistent connection to `/v1/responses` and continue each turn by sending only new input items plus `previous_response_id`. - -WebSocket mode is compatible with both Zero Data Retention (ZDR) and `store=false`. - -## OpenAI Realtime WebSocket bridge - -Configure an active `openai:realtime` provider endpoint, then connect to: - -```text -wss:///v1/realtime?model= -``` - -Aether authenticates and plans the request before returning the downstream -WebSocket upgrade. The global model alias is replaced in the upstream query, -while safe non-credential query parameters, provider authentication, -`header_rules`, and proxy settings continue to apply. Client credentials in -the query string are rejected or removed rather than forwarded upstream. - -After the handshake, Aether relays text, binary, ping, pong, and close frames -one at a time. JSON events, Base64 audio payloads, and unknown future fields are -not rebuilt or coalesced. When an upstream `response.done` contains an -authoritative `response.usage`, its text/audio token counters are accumulated -for the connection's usage record. A session that closes without authoritative -usage is recorded as `usage_available=false`; Aether does not estimate token -counts, audio duration, or cost from frame sizes. - -`response.done` covers Realtime Response usage. Optional input transcription -is reported by a different event and can use a different transcription model; -it is not folded into the Response model's session row or priced as if it used -that model. Finite-balance Realtime access therefore remains fail-closed until -multi-event, multi-model settlement is implemented. - -The upstream handshake is completed before Aether sends HTTP 101 to the -client. A provider authentication, TLS, proxy, or upgrade failure therefore -returns an ordinary bounded HTTP error instead of opening a socket that fails -immediately. - -See the official [OpenAI Realtime WebSocket guide](https://developers.openai.com/api/docs/guides/realtime-websocket) -for the current event contract. - -## Experimental Codex Live bridge - -Aether also exposes the Codex Frameless Bidi V3 transport used by current -Codex clients. It is related to the OpenAI Realtime API, but it is not the -Responses WebSocket protocol and never enters Aether's `response.create` -state machine: - -- Current WebRTC call creation: `POST /v1/realtime/calls?intent=quicksilver&architecture=avas` - with bounded `sdp` and `session` multipart parts. Aether normalizes those - selectors, applies the global-to-provider mapping, and rewrites the upstream - `Location` to `/v1/realtime/calls/`. -- Current WebRTC sideband: `GET /v1/realtime?intent=quicksilver&call_id=`. - The unique `intent=quicksilver` selector classifies it as Codex Live; - `call_id` alone remains on the separate OpenAI Realtime surface. -- Current direct WebSocket (Realtime v2): - `GET /v1/realtime?model=`. V2 intentionally omits - `intent=quicksilver`; Aether uses the first-party Codex `originator` header - to distinguish it from ordinary OpenAI Realtime. The first client event is - still the opaque `session.update` frame generated by Codex; Aether forwards - it without converting it into a Responses `response.create` event. -- Realtime v1 direct WebSocket uses - `GET /v1/realtime?intent=quicksilver&model=` and - sends `openai-alpha: quicksilver=v1` upstream. V2 does not send that header. -- Legacy direct WebSocket: `GET /v1/live?model=`. The first client text - frame must be `session.update`; later text, binary, ping, pong, and close - frames are relayed opaquely. -- Legacy WebRTC call creation: `POST /v1/live` with bounded `sdp` and `session` - multipart parts. Aether applies the existing global-to-provider model - mapping and rewrites the upstream `Location` to `/v1/live/`. -- Legacy WebRTC sideband: `GET /v1/live/`. Frameless sideband attaches to an - already initialized call, so Aether neither waits for nor sends a second - `session.update` frame. - -The provider must expose an active, dedicated `codex:live` endpoint. Fixed -Codex providers receive this endpoint from the managed provider template; -custom providers can add it in the endpoint editor. The -`responses_websocket.enabled` provider option belongs only to -`openai:responses` WebSocket mode and is not reused as the Live permission. - -Codex Live also requires an authorized model mapping; adding the endpoint alone -is not enough. For WebRTC, Codex sends the selected model in the multipart -`session.model`. Aether treats that value as the downstream global model, -selects an existing mapping whose provider endpoint is `codex:live`, and -rewrites only `session.model` to the mapped upstream model. Configure Codex's -realtime model selection to an authorized global alias (the current Codex -default may be `gpt-realtime-1.5`, but that value is client-version dependent), -or create an authorized mapping for the alias the client already sends. Aether -does not invent a Live model name or add a bundled hard-coded model merely -because the endpoint is enabled. For Codex providers only, an existing model mapping scoped to -`openai:responses` (including the historical `/v1/responses` alias) can be -reused for Live. The provider endpoint and key must still explicitly allow -`codex:live`; OpenAI and custom providers do not receive this compatibility -rule. - -For Codex Desktop/app-server, point both the call-creation and sideband -overrides at the same Aether origin when using a custom provider. Explicitly -setting both avoids a client-version-dependent fallback to the OpenAI origin: - -```toml -model_provider = "aether" -experimental_realtime_webrtc_call_base_url = "https:///v1" -experimental_realtime_ws_base_url = "https:///v1" -experimental_realtime_ws_model = "" - -[model_providers.aether] -name = "Aether" -base_url = "https:///v1" -wire_api = "responses" -``` - -The two experimental overrides are optional when the selected provider's -`base_url` already points at Aether, but if either is set they must resolve to -the same deployment so the authenticated call binding can be found. A missing -call-create request in Aether means the client did not select this provider or -failed before gateway routing; a call-create request without the matching -sideband usually means the sideband origin or credential differs. These -settings may change with newer Codex releases. - -API-key and bearer providers can use direct WebSocket or WebRTC. ChatGPT OAuth -uses the official Codex backend for WebRTC call creation and the OpenAI Live -origin for its sideband; direct OAuth WebSocket and custom OAuth backend -origins fail closed. The call binding fixes the authenticated downstream -principal, provider/endpoint/key, mapped model, auth mode, account/FedRAMP -identity, session identity, and upstream origin. Raw call IDs are hashed in -RuntimeState keys, records expire after two hours, each principal retains at -most 64 call bindings, and one call permits only one renewable sideband -attachment at a time. The memory RuntimeState backend loses these bindings on -restart. The two-hour binding TTL and 64-record cap bound routing state and -abuse; they are not provider-concurrency reservations. - -Frameless V3 currently has no stable usage object that Aether can settle into -its wallet pipeline. Aether therefore enables Live only for principals without -a finite `balance_remaining`; finite-balance keys receive an explicit local -error instead of unmetered service. Aether writes one lifecycle record for each -relayed direct or sideband WebSocket connection, with frame/byte counts and -`usage_available=false`; it does not create one database row per audio frame. -The synchronous WebRTC call-creation exchange keeps its ordinary HTTP record -and is also marked usage-unavailable. The WebRTC media leg itself does not -traverse Aether after call creation, so Aether cannot observe or invent a -separate audio-session usage record, token count, duration, or cost for it. - -Aether-relayed direct and sideband WebSocket connections are limited to 60 -minutes. The provider-pool and admission leases cover only the synchronous -HTTP call-creation exchange and are released after its SDP response. Aether -cannot infer media lifetime from the binding TTL or sideband lifetime, so a -created call that never attaches a sideband is not held against provider -concurrency after call creation. - -For the public GA Realtime API's connection and session concepts, see the -[OpenAI Realtime guide](https://developers.openai.com/api/docs/guides/realtime), -[WebRTC connection guide](https://developers.openai.com/api/docs/guides/realtime-webrtc), -and [server-side controls guide](https://developers.openai.com/api/docs/guides/realtime-server-controls). - -OpenAI's current WebSocket service supports named `stream_id` lanes: requests on -the same lane are FIFO, while different lanes may run concurrently. Aether's -bridge currently exposes only the implicit default lane and deliberately -rejects `response.create.stream_id` until per-lane binding, ordering, timeout, -usage, and error routing are implemented end to end. Use separate WebSocket -connections for parallel runs through Aether. A syntactically valid named -`stream_id` is rejected with `responses_websocket_named_stream_unsupported`; -the error event echoes the validated ID so the client can associate the error -with its attempted lane. Invalid or untrusted IDs are not echoed. - -## Why use WebSocket mode - -WebSocket mode is most useful when a workflow involves many model-tool round trips (for example, agentic coding or orchestration loops with repeated tool calls). - -Because the connection stays open and each turn sends only incremental input, WebSocket mode reduces per-turn continuation overhead and improves end-to-end latency across long chains. The [OpenAI WebSocket-mode guide](https://developers.openai.com/api/docs/guides/websocket-mode) reports up to roughly 40% faster end-to-end execution for workloads with 20 or more tool calls; this is an upstream product claim, not an Aether benchmark. - -## Connect and create responses - -In WebSocket mode, start each turn by sending a `response.create` event from the client. The payload mirrors the normal [Responses create body](https://developers.openai.com/api/reference/resources/responses/methods/create), except that transport-specific fields like `stream` and `background` are not used. - -```python -from websocket import create_connection -import json -import os - -ws = create_connection( - "wss://api.openai.com/v1/responses", - header=[ - f"Authorization: Bearer {os.environ['OPENAI_API_KEY']}", - ], -) - -ws.send( - json.dumps( - { - "type": "response.create", - "model": "gpt-5.6", - "store": False, - "input": [ - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "Find fizz_buzz()"}], - } - ], - "tools": [], - } - ) -) -``` - - -Clients can optionally warm up request state by sending `response.create` with `generate: false`. This is useful when you already know the tools, instructions, and/or custom messages you plan to send with an upcoming turn. `generate: false` does not return a model output, but prepares request state so the next generated turn can start faster. The warmup request returns a response ID that you can chain from with `previous_response_id`, including on later turns in a response chain. The next section explains how to continue a session using `previous_response_id` and incremental inputs. - -## Continue with incremental inputs - -To continue a run, send another `response.create` with: - -- `previous_response_id` set to the prior response ID. -- `input` containing only new items (for example, tool outputs and the next user message). - -```python -ws.send( - json.dumps( - { - "type": "response.create", - "model": "gpt-5.6", - "store": False, - "previous_response_id": "resp_123", - "input": [ - { - "type": "function_call_output", - "call_id": "call_123", - "output": "tool result", - }, - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "Now optimize it."}], - }, - ], - "tools": [], - } - ) -) -``` - - -## How continuation works - -WebSocket mode uses the same `previous_response_id` chaining semantics as HTTP mode, but it adds a lower-latency continuation path on the active socket. - -On an active Aether WebSocket connection, the selected upstream keeps the -previous-response state for the single default lane in its connection-local -cache. Continuing from that most recent response is fast because the service -can reuse connection-local state. Because the previous-response state is -retained only in memory and is not written to disk, you can use WebSocket mode -in a way that is compatible with `store=false` and Zero Data Retention (ZDR). - -If a `previous_response_id` is not in the upstream connection's in-memory -cache, behavior depends on whether the upstream stored the response: - -- With `store=true`, the upstream service may hydrate older response IDs from its persisted state when available. Continuation can still work, but it usually loses the in-memory latency benefit. -- With `store=false` (including ZDR), there is no persisted fallback. If the ID is uncached, the request returns `previous_response_not_found`. - -For a new downstream WebSocket connection, Aether also has to prove that the -response belongs to the currently authenticated user/API key and to the exact -provider endpoint, key, credential generation, transport, adapter, model, and -normalization contract. Aether records this ownership only when the effective -provider `response.create`, after Aether's body rules and framing, explicitly -has `store=true` and a successful -`response.completed`, `response.done`, or non-error `response.incomplete` -terminal supplies a valid response ID. `store=false`, an omitted/overridden -`store`, failures, cancellations, malformed IDs, and ZDR turns never create -this registry state. This explicit-true rule is intentionally conservative: -Aether does not infer a provider default for an omitted `store` field. - -The RuntimeState key contains a SHA-256 digest over length-delimited live -`user_id`, `api_key_id`, and the opaque response ID; raw response IDs and -credentials are not stored in keys or values. Records expire after 24 hours, -are capped at the 1,024 most recently registered IDs per user/API-key pair, -and are bounded in serialized size. The registry stores ownership/routing -proof and contract digests only; it does not store response contents and is -not a replacement for the upstream's `store=true` persistence. A registry -write failure does not turn a successful provider response into a failure, so -that terminal can reach the client but cannot later resume on a new socket. - -With the Redis RuntimeState backend, ownership is shared across gateway -instances for the record TTL, subject to that Redis deployment's own -availability and persistence configuration. The memory backend is -process-local, is not shared between instances, and loses the registry on -restart. Expiry, per-principal eviction, a RuntimeState outage, or a memory -backend restart causes the first continuation on a new connection to fail -closed with `previous_response_not_found`, even if the upstream might still -retain the response. Aether never falls back to the ordinary scheduler for -such a miss and never sends the opaque response ID to a different provider or -key. - -PII-redaction restore mappings intentionally remain connection-local and are -not persisted. If a stored response chain contains Aether PII sentinels, Aether -rejects cross-connection continuation rather than risk exposing those -sentinels without the original restore mapping. Start a new response with the -complete required context in that case. - -If a continuation on the same lane fails (`4xx` or `5xx`), the service evicts -the referenced `previous_response_id` from the connection-local cache. Aether -only supports the implicit default lane, so this same-lane rule applies to all -continuations it currently accepts. The upstream service preserves a shared -parent when a cross-lane fork fails, but Aether does not yet expose that named -lane behavior. - -The continuation must keep the model selected for the response chain. Aether -rejects a model change with status `409` and code -`responses_continuation_model_change_unsupported`. - -## Compaction and creating new responses - -If you are using compaction, there are two different continuation patterns: - -### Server-side compaction (`context_management`) - -When you enable server-side compaction (`context_management` with `compact_threshold`), compaction happens during normal `/responses` generation. In WebSocket mode, you continue the same way you normally do: send the next `response.create` with the latest `previous_response_id` and only new input items. - -### Standalone `/responses/compact` - -The standalone [`/responses/compact` endpoint](https://developers.openai.com/api/reference/resources/responses/methods/compact) returns a new compacted input window, not a response ID. After compaction, create a new response on your WebSocket connection using the compacted window as `input` (plus the next user/tool items). - -Start a new chain by omitting `previous_response_id` or setting it to `null`. Pass the compacted output as-is; do not prune the returned window. - -```python -# Compact your current window (HTTP call) -compacted = client.responses.compact( - model="gpt-5.6", - input=long_input_items_array, -) - -# Start a new response on the WebSocket using the compacted window -ws.send( - json.dumps( - { - "type": "response.create", - "model": "gpt-5.6", - "store": False, - "input": [ - *compacted.output, - { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "Continue from here."}], - }, - ], - "tools": [], - } - ) -) -``` - - -## Connection behavior and limits - -- Server events and ordering match the existing Responses streaming event model. -- A single Aether WebSocket connection can receive multiple `response.create` messages over its lifetime, but the client must wait for a terminal event before sending the next one. Aether does not queue overlapping creates and returns `response_already_in_progress` while a turn is active. -- Named `stream_id` multiplexing is not exposed by Aether yet. Use multiple connections if you need parallel runs. -- The upstream OpenAI service allows at most 16 active/in-flight responses on one connection; additional `response.create` events are queued. It also allows at most 32 distinct named `stream_id` values per connection, and the implicit default lane does not count toward that 32-lane limit. These describe upstream multiplexing limits, not capabilities exposed by Aether's current single-lane bridge. -- Connection duration is limited to 60 minutes. Reconnect when the limit is reached. -- Aether binds each upstream WebSocket to one selected provider key. A provider must explicitly enable the standard Responses WebSocket capability and expose an `openai:responses` endpoint before it is eligible for this bridge. -- The Codex adapter additionally watches Codex quota events. A `usage_limit_reached` terminal error immediately marks the bound account unavailable. If the client has not received a standard `response.*` event and the request has no `previous_response_id`, Aether retries that one turn once on another eligible key without closing the public socket. -- After a standard response event has reached the client, after a retry has already been attempted, or for a request using `previous_response_id`, Aether forwards the provider terminal error and detaches only the exhausted upstream. If the upstream closes immediately after the quota signal, Aether emits a recoverable gateway error instead. The public WebSocket stays open so a later independent `response.create` can select another key. -- Aether does not transparently move an existing response chain to another provider key. Connection-local `previous_response_id` state cannot be transferred safely, especially with `store=false`/ZDR; send a new request with complete input after an exhausted continuation. - -## Reconnect and recover - -When a connection closes (or hits the 60-minute limit), open a new WebSocket -connection and continue with one of these patterns: - -1. If the prior response is persisted (`store=true`) and its response ID remains valid, continue with `previous_response_id` and only the new input items. -2. If the chain cannot be hydrated (for example, `store=false`/ZDR or `previous_response_not_found`), start a new response by setting `previous_response_id` to `null` (or omitting it) and send the complete input context needed for the next turn. -3. If you compacted context with `/responses/compact`, use the returned compacted window as the base `input` for that new response, then append the latest user/tool items. - -## Errors to handle - -`previous_response_not_found` - -```json -{ - "type": "error", - "status": 400, - "error": { - "type": "invalid_request_error", - "code": "previous_response_not_found", - "message": "Previous response with id 'resp_abc' not found.", - "param": "previous_response_id" - } -} -``` - -`websocket_connection_limit_reached` - -```json -{ - "type": "error", - "error": { - "type": "invalid_request_error", - "code": "websocket_connection_limit_reached", - "message": "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue." - }, - "status": 400 -} -``` - -## Related guides - -- [Conversation state](https://developers.openai.com/api/docs/guides/conversation-state) -- [Streaming API responses](https://developers.openai.com/api/docs/guides/streaming-responses) -- [Responses streaming events reference](https://developers.openai.com/api/reference/resources/responses) -- [Responses WebSocket events reference](https://developers.openai.com/api/reference/resources/responses/websocket-events) diff --git a/docs/api/embeddings.md b/docs/api/embeddings.md deleted file mode 100644 index 77cdb25ec..000000000 --- a/docs/api/embeddings.md +++ /dev/null @@ -1,222 +0,0 @@ -# Embeddings API - -Aether supports OpenAI compatible embedding requests through `POST /v1/embeddings`. Embedding requests are separate from chat and responses requests. They use `input`, never `messages`, and they are always non streaming. - -## Quick Start - -Run this against your Aether gateway URL with a user API key that can access the model and the `openai:embedding` API format. - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "text-embedding-3-small", - "input": ["hello", "world"], - "encoding_format": "float" - }' -``` - -## Public Endpoint - -| Method | Path | Client API format | Route kind | -| --- | --- | --- | --- | -| `POST` | `/v1/embeddings` | `openai:embedding` | `embedding` | - -The gateway classifies this endpoint as an OpenAI family embedding route with endpoint signature `openai:embedding`. It is not handled as chat or responses. - -## Request Body - -Required fields: - -| Field | Type | Notes | -| --- | --- | --- | -| `model` | string | Must name a model allowed for the API key and user. Blank strings are rejected. | -| `input` | string, string array, integer token array, nested integer token arrays, or multimodal object array | Must be non empty. Empty strings, empty arrays, empty token arrays, and empty multimodal objects are rejected. | - -Optional fields that pass through the embedding conversion path when supported by the provider: - -| Field | Notes | -| --- | --- | -| `encoding_format` | Passed to OpenAI compatible providers. | -| `dimensions` | Passed to providers whose embedding request shape supports it. | -| `parameters` | Provider-specific embedding parameters. For Aliyun DashScope this maps to DashScope `parameters`; `dimensions` is emitted as `parameters.dimension` unless `parameters.dimension` is already set. | -| `user` | Passed to OpenAI compatible providers. | -| `task` | Passed to Jina and OpenAI compatible embedding requests. Jina defaults to `text-matching` when no task is supplied. | - -Accepted `input` shapes: - -```json -{ "model": "text-embedding-3-small", "input": "hello" } -``` - -```json -{ "model": "text-embedding-3-small", "input": ["hello", "world"] } -``` - -```json -{ "model": "text-embedding-3-small", "input": [1, 2, 3] } -``` - -```json -{ "model": "text-embedding-3-small", "input": [[1, 2], [3, 4]] } -``` - -```json -{ - "model": "qwen3-vl-embedding", - "input": [ - { "text": "white running shoes" }, - { "image": "https://dashscope.oss-cn-beijing.aliyuncs.com/images/256_1.png" } - ], - "parameters": { "enable_fusion": true } -} -``` - -Use string or string array input when routing to Gemini or Doubao embedding providers. Token arrays are accepted by the OpenAI compatible public endpoint, but Gemini, Doubao, and Aliyun provider request emitters require text or multimodal content input. - -## Provider Format Mapping - -Embedding routes can select only embedding provider API formats. Chat, responses, image, and generation formats are not valid provider targets for this request type. - -| Provider API format | Upstream path shape | Provider request shape | -| --- | --- | --- | -| `openai:embedding` | `/v1/embeddings` | OpenAI compatible `{ "model", "input" }` payload. | -| `jina:embedding` | `/v1/embeddings` | OpenAI compatible payload with a Jina `task`. Defaults to `text-matching` if omitted. | -| `gemini:embedding` | `models/{model}:embedContent` | Single text input uses `content.parts[].text`. Multiple text inputs use `requests[].content.parts[].text`. | -| `doubao:embedding` | `/embeddings/multimodal` | Text input is emitted as `input` items like `{ "type": "text", "text": "..." }`. | -| `aliyun:multimodal_embedding` | `/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding` | Text and multimodal inputs are emitted as DashScope `input.contents`. Supports `text`, `image`, `video`, `multi_images`, `parameters.enable_fusion`, `parameters.res_level`, and `parameters.max_video_frames`. Alias: `dashscope:multimodal_embedding`. | - -Custom provider endpoint paths are available when the endpoint is configured for an embedding API format. Gemini custom paths can use `{model}` and `{action}`. For `gemini:embedding`, `{action}` expands to `embedContent`. - -## Model And Catalog Requirements - -To use embeddings through the gateway: - -1. The global model should include embedding metadata, for example `supported_capabilities: ["embedding"]`, `config.model_type: "embedding"`, or `config.api_formats` with one of the embedding formats. -2. The provider model or mapping must expose an embedding API format, one of `openai:embedding`, `gemini:embedding`, `jina:embedding`, `doubao:embedding`, or `aliyun:multimodal_embedding`. -3. The user and API key must be allowed to access the model and the `openai:embedding` client API format. -4. Public and admin catalog responses expose `supports_embedding` so clients can display embedding capability separately from chat. - -Billing fails closed for embedding global models. A model marked as embedding capable must define either `default_price_per_request` or `default_tiered_pricing.tiers[].input_price_per_1m`. Missing request pricing and missing input token pricing cause the model record to be rejected instead of treated as free. - -No schema migration is needed for embedding metadata. Existing model capability, config, provider mapping, API format, and pricing fields carry the data. - -## Aliyun Qwen3-VL Examples - -Text request through Aether: - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3-vl-embedding", - "input": "white running shoes", - "dimensions": 1024 - }' -``` - -Image and text fusion request: - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3-vl-embedding", - "input": [ - { "text": "white running shoes, lightweight and breathable" }, - { "image": "https://dashscope.oss-cn-beijing.aliyuncs.com/images/256_1.png" } - ], - "parameters": { "enable_fusion": true } - }' -``` - -Video request: - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3-vl-embedding", - "input": [ - { "video": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250107/lbcemt/new+video.mp4" } - ], - "parameters": { "max_video_frames": 64 } - }' -``` - -Multi-image fusion request: - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3-vl-embedding", - "input": [ - { "text": "product photos from multiple angles" }, - { "multi_images": [ - "https://example.com/front.png", - "https://example.com/side.png" - ] } - ], - "parameters": { "enable_fusion": true } - }' -``` - -## Failure Behavior - -The gateway validates deterministic request errors before local execution or provider transport. - -| Case | Example request body or setup | Status | Error detail | -| --- | --- | --- | --- | -| Invalid JSON | `{` | `400` | `Embedding request JSON body is invalid` | -| Missing model | `{ "input": "hello" }` | `400` | `Embedding request model is required` | -| Empty input | `{ "model": "text-embedding-3-small", "input": [] }` | `400` | `Embedding request input is required` | -| Chat `messages` payload | `{ "model": "text-embedding-3-small", "messages": [] }` | `400` | `Embedding request must use input, not chat messages` | -| Streaming requested | `{ "model": "text-embedding-3-small", "input": "hello", "stream": true }` | `400` | `Embedding requests do not support streaming` | -| Non JSON content type | `Content-Type: text/plain` with an embedding JSON body | `400` | `Embedding request content-type must be application/json` | -| Chat only model | API key allows `text-embedding-3-small`, request uses `gpt-5` | `403` | The key is not allowed to access that model. | -| Chat only API format | API key allows `openai:chat` but not `openai:embedding` | `403` | The key is not allowed to access `openai:embedding`. | - -Failure examples: - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{"model":"text-embedding-3-small","messages":[]}' -``` - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{"input":"hello"}' -``` - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{"model":"text-embedding-3-small","input":[]}' -``` - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: application/json" \ - -d '{"model":"text-embedding-3-small","input":"hello","stream":true}' -``` - -```bash -curl -sS "http://localhost:8084/v1/embeddings" \ - -H "Authorization: Bearer sk-your-aether-key" \ - -H "Content-Type: text/plain" \ - -d '{"model":"text-embedding-3-small","input":"hello"}' -``` - -If a valid embedding request passes local validation but no usable provider transport is available, the gateway can return a provider or service availability error. That is different from the deterministic request validation errors above. diff --git a/docs/api/format-conversion-audit.md b/docs/api/format-conversion-audit.md deleted file mode 100644 index 3cdafba31..000000000 --- a/docs/api/format-conversion-audit.md +++ /dev/null @@ -1,238 +0,0 @@ -# Format Conversion Audit - -Last audited: 2026-06-03 - -This audit tracks `source format -> Canonical -> target format` behavior. It is intentionally stricter than historical best-effort conversion. - -Statuses: - -- `native`: emitted as a target-native field without semantic change. -- `mapped`: converted through canonical/provider-specific mapping. -- `extension-preserved`: preserved in same-format canonical roundtrip or target-approved extension namespace. -- `transport-only`: audited client transport metadata intentionally omitted when the target has no compatible transport channel. -- `unaudited`: rejected because the source field is not in the audited provider schema inventory for cross-format conversion. -- `unsupported`: rejected because the request/field shape is outside the supported conversion surface, independent of schema drift. -- `lossy-blocked`: conversion fails closed. -- `invalid-enum`: conversion fails closed because a provider enum value is not valid for the target mapping. - -Full schema field coverage is tracked in `docs/api/format-field-coverage-matrix.md`. That matrix is generated from the schema inventory in `docs/api/provider-interface-definitions.md` by `python3 docs/api/generate_format_field_coverage.py` and gives every documented OpenAI, Claude, and Gemini schema field a handling status. “Handled” means mapped, same-format/native preserved, extension-preserved, blocked with a structured error, or explicitly marked outside the canonical conversion surface. - -Provider schema refresh is not a runtime dependency. Same-format runtime paths do not use this matrix; they bypass canonical conversion. Same-format canonical roundtrip must preserve unrecognized provider fields through provider extension namespaces. Cross-format conversion is capability-based: only explicitly mapped fields are emitted, and newly discovered or unknown provider fields fail closed with `UnauditedField` until a lossless mapping is audited. - -## Implemented Boundary Changes - -| Area | Current behavior | -| --- | --- | -| Pure conversion API | `convert_request_pure` and response equivalents do not apply model override or stream policy. | -| Legacy conversion API | `convert_request` / `convert_response` are retained for migration and may still use legacy context behavior. | -| Same-format provider path | Bypasses canonical conversion and copies the parsed JSON object before transport edits. | -| Cross-format same-format-provider path | Uses `convert_request_pure`, then applies model/body/stream edits in transport. | -| Conversion errors | Added `UnauditedField`, `UnsupportedField`, `InvalidEnumValue`, `LossyConversionBlocked`, and `InvalidTargetField`. | -| Reporting | Added `ConversionReport` with field statuses. Runtime reports remain conversion-operation oriented; exhaustive nested schema coverage is enforced by `format-field-coverage-matrix.md`. | -| Source schema coverage | Cross-format request conversion rejects unknown source root fields before emit. Every documented schema field is covered by the field coverage matrix. | -| Schema drift handling | Official schema changes are detected by regenerating the inventory/matrix. Runtime same-format remains passthrough; cross-format unknowns return `UnauditedField` until deliberately mapped. | -| Tool schema roundtrip | Claude `input_schema` and Gemini `functionDeclarations.parameters` preserve raw same-format schema through provider-specific extensions. | -| Tool result ids | Chat `tool_call_id`, Responses `call_id`, Claude `tool_use_id`, and Gemini `functionResponse.id` are mapped through canonical tool IDs. | - -## OpenAI Chat -> OpenAI Responses - -| Chat field | Canonical handling | Responses output | Status | -| --- | --- | --- | --- | -| `model` | request identity | `model` | native | -| `messages` | canonical messages/instructions | `input`, `instructions` | mapped | -| `max_tokens` | generation max tokens | `max_output_tokens` | mapped | -| `max_completion_tokens` | generation max tokens | `max_output_tokens` | mapped | -| `temperature` | generation | `temperature` | native | -| `top_p` | generation | `top_p` | native | -| `top_logprobs` | generation | `top_logprobs` | native | -| `n` | generation but no Responses equivalent | none | lossy-blocked | -| `stop` | generation but no Responses equivalent | none | lossy-blocked | -| `presence_penalty` | generation but no Responses equivalent | none | lossy-blocked | -| `frequency_penalty` | generation but no Responses equivalent | none | lossy-blocked | -| `seed` | generation but no Responses equivalent | none | lossy-blocked | -| `logprobs` | generation but no Responses equivalent | none | lossy-blocked | -| `stream` | OpenAI extension | `stream` | mapped if explicit | -| `stream_options` | Chat-specific extension | none | lossy-blocked | -| `tools[].function.name` | canonical tool | `tools[].name` | mapped | -| `tools[].function.description` | canonical tool | `tools[].description` | mapped | -| `tools[].function.parameters` | canonical tool | `tools[].parameters` | mapped | -| `tools[].function.strict` | canonical tool strict | `tools[].strict` | mapped, implemented | -| assistant `tool_calls[].id` | canonical tool use id | `function_call.call_id` | mapped, implemented | -| tool message `tool_call_id` | canonical tool result id | `function_call_output.call_id` | mapped, implemented | -| `tool_choice` | canonical tool choice | `tool_choice` | mapped | -| `parallel_tool_calls` | canonical bool | `parallel_tool_calls` | native | -| `metadata` | canonical metadata | `metadata` | native | -| `response_format` | canonical response format | `text.format` | mapped | -| `reasoning_effort` | OpenAI enum | `reasoning.effort` | mapped; invalid enum blocked | -| `verbosity` | OpenAI extension | `text.verbosity` | mapped | -| `store` | OpenAI extension | `store` | extension-preserved | -| `service_tier` | OpenAI extension | `service_tier` | extension-preserved | -| `safety_identifier` | OpenAI extension | `safety_identifier` | extension-preserved | -| `prompt_cache_key` | OpenAI extension | `prompt_cache_key` | extension-preserved | -| `user` | legacy Chat user field | none | lossy-blocked | -| unknown top-level fields | source schema guard | none | unaudited | - -## OpenAI Responses -> OpenAI Chat - -| Responses field | Canonical handling | Chat output | Status | -| --- | --- | --- | --- | -| `model` | request identity | `model` | native | -| `input` | canonical messages/content/tool I/O | `messages` | mapped | -| `instructions` | canonical instruction/system | `messages` system/developer | mapped | -| `max_output_tokens` | generation max tokens | `max_completion_tokens` | mapped | -| `temperature` | generation | `temperature` | native | -| `top_p` | generation | `top_p` | native | -| `top_logprobs` | generation | `top_logprobs` | native | -| `metadata` | canonical metadata | `metadata` | native | -| `client_metadata` | Responses client transport metadata | none | transport-only; omitted | -| `parallel_tool_calls` | canonical bool | `parallel_tool_calls` | native | -| `text.format` | canonical response format | `response_format` | mapped | -| `text.verbosity` | Responses extension | `verbosity` | mapped | -| `tools[].type=function` | canonical tool | `tools[].type=function` | mapped | -| `tools[].name` | canonical tool | `tools[].function.name` | mapped | -| `tools[].parameters` | canonical tool | `tools[].function.parameters` | mapped | -| `tools[].strict` | canonical tool strict | `tools[].function.strict` | mapped, implemented | -| `function_call.call_id` | canonical tool use id | `tool_calls[].id` | mapped, implemented | -| `function_call_output.call_id` | canonical tool result id | tool message `tool_call_id` | mapped, implemented | -| `tools[].type=custom` | raw Responses tool | none | lossy-blocked to Chat | -| `tools[].type=web_search*` | raw Responses tool | none | lossy-blocked to Chat | -| `tool_choice` | canonical tool choice | `tool_choice` | mapped | -| `reasoning.effort` | OpenAI enum | `reasoning_effort` | mapped; invalid enum blocked | -| `reasoning.summary` | Responses-only | none | lossy-blocked | -| `reasoning.budget_tokens` | Responses-only | none | lossy-blocked | -| `stream` | Responses request transport policy | none | lossy-blocked; target stream policy is transport-owned | -| `include` | Responses-only | none | lossy-blocked; legacy emitter no longer leaks | -| `previous_response_id` | Responses-only | none | lossy-blocked; legacy emitter no longer leaks | -| `truncation` | Responses-only | none | lossy-blocked | -| `prompt` | Responses-only | none | lossy-blocked | -| `conversation` | Responses-only | none | lossy-blocked | -| `background` | Responses-only | none | lossy-blocked | -| `max_tool_calls` | Responses-only | none | lossy-blocked | -| unknown top-level fields | source schema guard | none | unaudited | - -## Claude Messages <-> OpenAI Chat / Responses - -Claude to OpenAI Chat, Claude to OpenAI Responses, and the reverse directions are included in the field coverage matrix. Runtime strict guards cover request root fields, provider extension namespaces, thinking/cache/tool-result hazards, and target generation-field gaps. Fields without a lossless target equivalent fail closed instead of being dropped. - -High-risk fields: - -| Claude field | OpenAI target risk | Required status | -| --- | --- | --- | -| `system` with cache blocks | Chat/Responses system instructions | same-format preserved; cross-format `cache_control` loss is blocked | -| `thinking` | OpenAI reasoning | Claude request-level thinking config maps to OpenAI reasoning; message-level thinking blocks are blocked for Responses | -| `cache_control` | OpenAI content/tool extensions | same-format preserved; cross-format blocked when no target equivalent exists | -| `tools[].input_schema` | OpenAI tool parameters | mapped; raw same-format schema preservation implemented | -| `tool_choice.disable_parallel_tool_use` | OpenAI `parallel_tool_calls` | mapped, implemented | -| `tool_result` multi-block content | OpenAI tool output/content | same-format preserved; cross-format to Chat/Responses is lossy-blocked | -| `metadata` | OpenAI metadata | mapped when the target has metadata | -| `container`, `inference_geo`, `service_tier` | OpenAI target has no audited equivalent | lossy-blocked unless a target-approved mapping is added | - -## Gemini GenerateContent <-> OpenAI Chat / Responses / Claude - -Gemini to OpenAI Chat, Gemini to OpenAI Responses, Gemini to Claude, and reverse generation paths are included in the field coverage matrix. Gemini-only request fields are preserved same-format and blocked cross-format unless the target mapping is explicitly audited. - -High-risk fields: - -| Gemini field | Target risk | Required status | -| --- | --- | --- | -| `contents[].parts[].thoughtSignature` | OpenAI/Claude thinking | Chat/Claude preserve; Responses cross-format is lossy-blocked | -| `tools[].functionDeclarations` | OpenAI/Claude tool schema | mapped; raw same-format `parameters` preservation implemented | -| `toolConfig.functionCallingConfig.allowedFunctionNames` | OpenAI/Claude tool choice | single-name mapping implemented; multi-name input is lossy-blocked | -| `toolConfig.functionCallingConfig.mode` | OpenAI/Claude tool choice enum | valid enum required; invalid values fail with `InvalidEnumValue` | -| `generationConfig.thinkingConfig.thinkingLevel` | OpenAI/Claude reasoning effort | low/medium/high mapping implemented; invalid values fail closed | -| `safetySettings` | OpenAI/Claude no direct equivalent | lossy-blocked | -| `cachedContent` | OpenAI/Claude no direct equivalent | lossy-blocked | -| `codeExecution` | OpenAI/Claude tool/builtin mismatch | lossy-blocked | -| `generationConfig.responseModalities` | OpenAI/Claude modality mismatch | lossy-blocked | -| `functionResponse.id` | tool result id | conversion preserves id; Gemini upstream cleanup is transport-layer edit only | - -## Embedding And Rerank - -Embedding and rerank request parse/emit capability and strict target guards are implemented. Provider schema fields outside these canonical conversion surfaces are marked `not-in-conversion-surface` in the field coverage matrix instead of being left implicit. - -Embedding source capability: - -| Source format | Parsed request shape | Canonical fields | Status | -| --- | --- | --- | --- | -| OpenAI Embedding | `model`, `input`, `encoding_format`, `dimensions`, `user`, `parameters`, `task` | OpenAI-like embedding | mapped | -| Jina Embedding | OpenAI-like plus provider extension namespace | OpenAI-like embedding | mapped | -| Doubao Embedding | OpenAI-like `model` + text `input` | OpenAI-like embedding | mapped | -| Gemini Embedding | single `content.parts[].text` or batch `requests[]` | text input, `dimensions`, `task` | mapped | -| Aliyun Multimodal Embedding | `input.contents[]`, `parameters.dimension` | text/multimodal input, `dimensions`, `parameters` | mapped | - -Embedding target guards: - -| Target format | Accepted canonical fields | Blocked fields/cases | Status | -| --- | --- | --- | --- | -| OpenAI Embedding | text or token input, `encoding_format`, `dimensions`, `user` | multimodal input, `task`, generic `parameters` | lossy-blocked | -| Jina Embedding | text input, `dimensions`, `task`, `parameters` | token/multimodal input, `encoding_format`, `user` | lossy-blocked | -| Gemini Embedding | text input, `dimensions`, valid `taskType` | token/multimodal input, `encoding_format`, `user`, generic `parameters`, invalid `taskType` | lossy-blocked / invalid-enum | -| Doubao Embedding | text input, `dimensions` | token/multimodal input, `encoding_format`, `user`, `task`, generic `parameters` | lossy-blocked | -| Aliyun Multimodal Embedding | text or multimodal input, `dimensions`, `parameters` | token input, `encoding_format`, `user`, `task` | lossy-blocked | - -Cross-format embedding invariants: - -- Embedding formats can only convert to embedding formats. -- Unknown provider-specific embedding extension namespaces are blocked cross-format unless the namespace matches the target. -- Aliyun `parameters.dimension` maps to canonical `dimensions` and is not treated as generic `parameters`. -- Gemini batch embedding parse requires every batch item to share the same model, dimensions, and task. - -Rerank first pass: - -| Area | Current behavior | Status | -| --- | --- | --- | -| Source formats | OpenAI Rerank and Jina Rerank parse OpenAI-like `model`, `query`, `documents`, `top_n`, `return_documents` | mapped | -| Target formats | OpenAI Rerank and Jina Rerank emit OpenAI-like rerank bodies | mapped | -| Boundary | Rerank formats can only convert to rerank formats | lossy-blocked | -| Validation | Empty query/documents and `top_n=0` fail closed | invalid-target-field | -| Extensions | Unknown provider-specific rerank extension namespaces are blocked cross-format | unsupported | - -## Sync Response Conversion - -Cross-format sync response conversion now validates source stop/finish/status -enums before emitting a target body. Same-format runtime response passthrough is -still outside canonical conversion. - -| Source field | Target risk | Current behavior | Status | -| --- | --- | --- | --- | -| Same-format response raw stop/status fields | canonical emitters would otherwise normalize unknown enum/status to default target stop values | raw OpenAI Chat `finish_reason`, OpenAI Responses `status`, Claude `stop_reason`/`stop_sequence`, and Gemini `finishReason` are preserved through provider extension metadata | extension-preserved | -| OpenAI Chat `choices[].finish_reason` | unknown value would otherwise emit as target normal stop | valid Chat enum required; unknown values fail with `InvalidEnumValue` | invalid-enum | -| OpenAI Responses `status` | `queued`, `in_progress`, and `cancelled` have no sync target equivalent | non-terminal valid states fail with `LossyConversionBlocked`; invalid states fail with `InvalidEnumValue` | lossy-blocked / invalid-enum | -| OpenAI Responses `incomplete_details.reason=content_filter` | previously mapped to max tokens/`length` | maps to canonical content filter and emits Chat `content_filter` / Claude `content_filtered` / Gemini `SAFETY` | mapped | -| Claude `stop_reason` | unknown value would otherwise emit as target normal stop | valid known stop enum required for cross-format conversion | invalid-enum | -| Gemini `candidates[].finishReason` | known-but-unmappable reasons would otherwise emit as target normal stop | mappable safety/max/stop reasons convert; known unmappable values such as `OTHER`, `MALFORMED_FUNCTION_CALL`, `UNEXPECTED_TOOL_CALL`, `MISSING_THOUGHT_SIGNATURE`, and `MALFORMED_RESPONSE` fail with `LossyConversionBlocked`; future unknown values fail with `InvalidEnumValue` | lossy-blocked / invalid-enum | -| Canonical `Unknown` stop reason | target emitters default to normal stop values | cross-format response conversion blocks canonical unknown stop reasons | lossy-blocked | - -## Stream Conversion - -Sixth batch first pass is implemented for unknown event handling and runtime -same-format boundaries. Sync response finish/status parity has a first strict -pass; stream finish-reason guardrails are implemented for unknown/unmappable -terminal reasons. Stream event schema fields are covered in the field coverage -matrix; provider-by-provider fixtures cover the runtime event behavior. - -Current stream behavior: - -| Area | Current behavior | Status | -| --- | --- | --- | -| Provider parsers | OpenAI Chat, OpenAI Responses, Claude, and Gemini unknown stream payloads become `CanonicalStreamEvent::UnknownEvent` | mapped | -| Cross-format stream matrix | Unknown canonical stream events emit a target-format error SSE with `unsupported_stream_event` and terminate conversion | lossy-blocked | -| Stream finish reason guard | Unknown OpenAI finish reasons, unknown Claude `stop_reason`, and Gemini known-but-unmappable `finishReason` values such as `OTHER` are preserved as raw canonical finish strings, then blocked by the matrix with `unsupported_finish_reason` | lossy-blocked | -| OpenAI Responses stream target | Canonical `length` and `content_filter` terminal reasons emit `response.incomplete` with `incomplete_details.reason=max_output_tokens` or `content_filter` instead of `response.completed` | mapped | -| Terminal observer | Unknown provider stream events increment `unknown_event_count`; OpenAI Responses failed events mark terminal error state | mapped | -| Stream -> sync aggregate | Unknown OpenAI Chat, OpenAI Responses, Claude, and Gemini stream events make the runtime finalize checked path return an error and block `body_json` fallback; legacy public aggregate helpers keep `Option` compatibility | lossy-blocked | -| Runtime strict fallback guard | `UnauditedField`, `InvalidEnumValue`, `UnsupportedField`, `LossyConversionBlocked`, and `InvalidTargetField` from registry response conversion are not allowed to fall through legacy conversion helpers | lossy-blocked | -| Runtime same-format stream | Same-format stream passthrough remains outside canonical conversion; stream policy edits are transport-layer only | native | - -Stream fixture coverage: - -| Provider stream | Covered fixture areas | -| --- | --- | -| OpenAI Chat | sync aggregation for text, tool call IDs/names/argument deltas, finish reason, and usage; cross-format unknown finish/event blocking | -| OpenAI Responses | text snapshot de-duplication, multi-part messages, reasoning/items, function calls, image generation calls, same-family stream sync, unknown event blocking | -| Claude Messages | thinking signatures, tool input deltas, cache/usage aggregation, media emission, unknown stop/event blocking | -| Gemini GenerateContent | text/media/signature aggregation, function calls/results, safety finish mapping, unknown parts/events, and unmappable finish reason blocking | - -Matrix-level interception remains the authoritative runtime path for cross-format -unknown events; direct client emitters are covered only as provider/client -building blocks. diff --git a/docs/api/format-enum-mapping.md b/docs/api/format-enum-mapping.md deleted file mode 100644 index 519dac292..000000000 --- a/docs/api/format-enum-mapping.md +++ /dev/null @@ -1,145 +0,0 @@ -# Format Enum Mapping - -Status values used below: - -- `native`: same semantic value exists in the target format. -- `mapped`: explicit provider-specific mapping is required. -- `blocked`: no lossless target value; conversion must fail closed. -- `preserve-same-format`: unknown/raw values are preserved only when source and target format are the same. - -## OpenAI Reasoning Effort - -Provider-specific types: `OpenAiChatReasoningEffort` for Chat `reasoning_effort`, and `OpenAiResponsesReasoningEffort` for Responses `reasoning.effort`. They are intentionally separate even when their current value sets overlap; a value accepted by one field is not treated as valid for the other unless that field's own enum accepts it. - -| Source field | Source value | Target field | Target value | Status | -| --- | --- | --- | --- | --- | -| Chat `reasoning_effort` | `none` | Responses `reasoning.effort` | `none` | native | -| Chat `reasoning_effort` | `minimal` | Responses `reasoning.effort` | `minimal` | native when the target model supports it; blocked for GPT-5.6 | -| Chat `reasoning_effort` | `low` | Responses `reasoning.effort` | `low` | native | -| Chat `reasoning_effort` | `medium` | Responses `reasoning.effort` | `medium` | native | -| Chat `reasoning_effort` | `high` | Responses `reasoning.effort` | `high` | native | -| Chat `reasoning_effort` | `xhigh` | Responses `reasoning.effort` | `xhigh` | native | -| Chat `reasoning_effort` | `max` | Responses `reasoning.effort` | `max` | native for GPT-5.6; blocked for models that do not publish `max` | -| Responses `reasoning.effort` | `none` | Chat `reasoning_effort` | `none` | native | -| Responses `reasoning.effort` | `minimal` | Chat `reasoning_effort` | `minimal` | native when the target model supports it; blocked for GPT-5.6 | -| Responses `reasoning.effort` | `low` | Chat `reasoning_effort` | `low` | native | -| Responses `reasoning.effort` | `medium` | Chat `reasoning_effort` | `medium` | native | -| Responses `reasoning.effort` | `high` | Chat `reasoning_effort` | `high` | native | -| Responses `reasoning.effort` | `xhigh` | Chat `reasoning_effort` | `xhigh` | native | -| Responses `reasoning.effort` | `max` | Chat `reasoning_effort` | `max` | native for GPT-5.6; blocked for models that do not publish `max` | -| Responses `reasoning.summary` | any | Chat | none | blocked | -| Responses `reasoning.budget_tokens` | any | Chat | none | blocked | - -Internal model directive values: - -| Internal value | OpenAI Chat | OpenAI Responses | Claude output effort | Gemini thinking level | Notes | -| --- | --- | --- | --- | --- | --- | -| `none` | `none` | `none` | `low` | `low` | Budget maps to `0`. | -| `minimal` | `minimal` | `minimal` | `low` | `low` | Budget maps to `512`. | -| `low` | `low` | `low` | `low` | `low` | Budget maps to `1280`. | -| `medium` | `medium` | `medium` | `medium` | `medium` | Budget maps to `2048`. | -| `high` | `high` | `high` | `high` | `high` | Budget maps to `4096`. | -| `xhigh` | `xhigh` | `xhigh` | `xhigh` | `high` | Budget maps to `8192`. | -| `max` | `max` for GPT-5.6 | `max` for GPT-5.6 | `max` | `high` | OpenAI emission is capability-gated by the resolved model. | - -GPT-5.6 (`gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`) publishes `none`, `low`, `medium`, `high`, `xhigh`, and `max`, and does not support `minimal`. Additional non-empty effort values advertised by a model are preserved verbatim across OpenAI Chat and Responses conversion. `ultra` is a Codex client preset that resolves to `max` before transmission and is not an OpenAI wire effort. Known effort capabilities are validated against the resolved provider model, so aliases mapped to GPT-5.6 receive the GPT-5.6 contract while concrete model families keep their published constraints. - -## Tool Choice - -| Canonical | OpenAI Chat | OpenAI Responses | Claude Messages | Gemini GenerateContent | -| --- | --- | --- | --- | --- | -| auto | `"auto"` | `"auto"` | `{"type":"auto"}` | unset / function calling config auto | -| none | `"none"` | `"none"` | `{"type":"none"}` | mode none | -| required | `"required"` | `"required"` | `{"type":"any"}` | mode any | -| named function | `{"type":"function","function":{"name":...}}` | `{"type":"function","name":...}` | `{"type":"tool","name":...}` | allowed function name | - -Implemented guardrails: - -- Claude `output_config.effort=max` maps to OpenAI `xhigh`; unknown Claude effort enums are blocked cross-format. -- Claude `tool_choice.disable_parallel_tool_use` maps inversely to OpenAI `parallel_tool_calls`. -- Gemini `allowedFunctionNames` maps to canonical named tool choice and emits back to `allowedFunctionNames`. -- Gemini `thinkingLevel` maps `low|medium|high` to OpenAI reasoning effort `low|medium|high`; unknown values are blocked cross-format. -- Responses `custom`, `web_search*`, and other built-in tools are blocked when converting to Chat unless a target raw passthrough is explicitly added. - -## Tool Definition Kind - -| Source | Target | Mapping | -| --- | --- | --- | -| OpenAI Chat `tools[].function.name` | Responses `tools[].name` | mapped | -| OpenAI Chat `tools[].function.parameters` | Responses `tools[].parameters` | mapped | -| OpenAI Chat `tools[].function.strict` | Responses `tools[].strict` | mapped, implemented | -| Responses `tools[].strict` | Chat `tools[].function.strict` | mapped, implemented | -| OpenAI Chat assistant `tool_calls[].id` | Responses `function_call.call_id` | mapped, implemented | -| OpenAI Chat tool `tool_call_id` | Responses `function_call_output.call_id` | mapped, implemented | -| Responses `function_call.call_id` | Chat `tool_calls[].id` | mapped, implemented | -| Responses `function_call_output.call_id` | Chat tool `tool_call_id` | mapped, implemented | -| Gemini `functionCall.id` | OpenAI/Claude canonical tool use id | mapped, implemented | -| Gemini `functionResponse.id` | OpenAI `tool_call_id` / Responses `call_id` / Claude `tool_use_id` | mapped, implemented | -| Claude `tools[].input_schema` | OpenAI `parameters` | mapped; raw same-format schema preserved | -| Gemini `functionDeclarations[].parameters` | OpenAI `parameters` | mapped; raw same-format schema preserved | - -## Roles - -| Canonical role | OpenAI Chat | OpenAI Responses | Claude Messages | Gemini | -| --- | --- | --- | --- | --- | -| system | `system` | `instructions` or system input item | top-level `system` | `systemInstruction` | -| developer | `developer` | `instructions` or developer input item | extension-preserved | systemInstruction extension | -| user | `user` | `message.role=user` | `user` | `user` | -| assistant | `assistant` | `message.role=assistant` / output item | `assistant` | `model` | -| tool | `tool` | `function_call_output` | `tool_result` inside user message | `functionResponse` | - -Known lossy risks: - -- Multiple system/developer instruction ordering needs full golden fixtures. -- Provider-specific role extensions must be preserved in same-format roundtrip and blocked cross-format if no target equivalent exists. - -## Finish Reasons - -| Canonical | OpenAI Chat | OpenAI Responses | Claude | Gemini | -| --- | --- | --- | --- | --- | -| stop | `stop` | completed output | `end_turn` | `STOP` | -| length | `length` | `status=incomplete`, `incomplete_details.reason=max_output_tokens` | `max_tokens` | `MAX_TOKENS` | -| tool calls | `tool_calls` | output contains `function_call` | `tool_use` | `functionCall` part, usually with `STOP` | -| content filter/safety | `content_filter` | `status=incomplete`, `incomplete_details.reason=content_filter` | `content_filtered` or refusal-compatible stops | `SAFETY`, `RECITATION`, `LANGUAGE`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `SPII`, image safety/recitation stops | -| unknown | preserve-same-format | preserve-same-format | preserve-same-format | preserve-same-format | - -Implemented response guardrails: - -- Cross-format sync response conversion validates source finish/status enums before emitting the target response. -- Same-format canonical response roundtrip preserves raw OpenAI Chat `finish_reason`, OpenAI Responses `status`, Claude `stop_reason`, and Gemini `finishReason` values through provider extension metadata. -- OpenAI Chat unknown `choices[].finish_reason` fails with `InvalidEnumValue`. -- OpenAI Responses non-terminal `status` values (`queued`, `in_progress`, `cancelled`) are valid provider states but are blocked for sync response conversion because target sync formats cannot represent them losslessly. -- Runtime sync finalize does not fall back to legacy conversion when registry response conversion reports strict errors such as invalid enums, unsupported fields, lossy blocks, or invalid target fields. -- Stream terminal reasons now follow the same strict policy: unknown OpenAI / Claude raw finish reasons and Gemini known-but-unmappable values such as `OTHER` surface as `unsupported_finish_reason`, while OpenAI Responses `length` and `content_filter` stream finals emit `response.incomplete`. -- Gemini known but unmappable finish reasons such as `OTHER`, `MALFORMED_FUNCTION_CALL`, `UNEXPECTED_TOOL_CALL`, `MISSING_THOUGHT_SIGNATURE`, and `MALFORMED_RESPONSE` are blocked with `LossyConversionBlocked`; unknown future Gemini values fail with `InvalidEnumValue`. -- Stream finish reason guardrails are covered with provider-specific fixtures for usage, tool calls, reasoning signatures, media, and unknown payloads. Unknown provider stream events are not mapped as finish reasons; cross-format runtime conversion emits a target-format `unsupported_stream_event` error and terminates. - -## Embedding Task Types - -Provider-specific type: `GeminiEmbeddingTaskType`. - -Gemini embedding task values are stored in canonical `embedding.task` only after -source parsing. They are emitted to Gemini as `taskType` and validated before -cross-format conversion to a Gemini target. - -| Canonical task input | Gemini `taskType` output | Status | -| --- | --- | --- | -| `QUERY` | `RETRIEVAL_QUERY` | mapped alias | -| `RETRIEVAL_QUERY` | `RETRIEVAL_QUERY` | native | -| `DOCUMENT` | `RETRIEVAL_DOCUMENT` | mapped alias | -| `RETRIEVAL_DOCUMENT` | `RETRIEVAL_DOCUMENT` | native | -| `TEXT_MATCHING` | `SEMANTIC_SIMILARITY` | mapped alias | -| `SEMANTIC_SIMILARITY` | `SEMANTIC_SIMILARITY` | native | -| `CLASSIFICATION` | `CLASSIFICATION` | native | -| `CLUSTERING` | `CLUSTERING` | native | -| `QUESTION_ANSWERING` | `QUESTION_ANSWERING` | native | -| `FACT_VERIFICATION` | `FACT_VERIFICATION` | native | -| `CODE_RETRIEVAL_QUERY` | `CODE_RETRIEVAL_QUERY` | native | -| `TASK_TYPE_UNSPECIFIED` | `TASK_TYPE_UNSPECIFIED` | native | -| unknown value | none | blocked with `InvalidEnumValue` when targeting Gemini | - -Cross-provider rules: - -- Gemini `taskType` to OpenAI Embedding is blocked because OpenAI has no equivalent task field. -- Gemini `taskType` to Doubao/Aliyun is blocked for the same reason. -- Jina `task` may carry through canonical and emit as Jina `task`; when targeting Gemini it must match the valid Gemini task set above. diff --git a/docs/api/format-field-coverage-matrix.md b/docs/api/format-field-coverage-matrix.md deleted file mode 100644 index 2c2103097..000000000 --- a/docs/api/format-field-coverage-matrix.md +++ /dev/null @@ -1,2095 +0,0 @@ -# Format Field Coverage Matrix - -Last generated: 2026-06-03 - -This file is generated from the schema inventory in `docs/api/provider-interface-definitions.md` and gives every documented schema field an explicit handling status. “处理到” here means the field is either mapped, preserved in same-format paths, rejected with a structured fail-closed error, or explicitly outside the current conversion surface. It does not mean every field can be cross-format converted. - -Provider schema updates do not require immediate conversion-code changes for runtime safety. Same-format runtime paths bypass canonical conversion, and same-format canonical roundtrip preserves provider extension fields. Cross-format conversion only enables fields with an audited semantic mapping; newly discovered or unknown provider fields default to structured fail-closed behavior until mapped. - -Regenerate with: `python3 docs/api/generate_format_field_coverage.py`. - -Statuses used in this matrix: `native`, `mapped`, `mapped/lossy-blocked`, `extension-preserved`, `unaudited`, `unsupported`, `invalid-enum`, `lossy-blocked`, `not-in-conversion-surface`. - -| Provider | Schema | Field | Required | Type | Surface | Same-Format Runtime | Canonical Roundtrip | Cross-Format | Notes | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| OpenAI | `AdditionalTools` | `id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalTools` | `role` | 是 | `MessageRole` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalTools` | `tools` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalTools` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalToolsItemParam` | `id` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalToolsItemParam` | `role` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalToolsItemParam` | `tools` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `AdditionalToolsItemParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchCreateFileOperation` | `diff` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchCreateFileOperation` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchCreateFileOperation` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchCreateFileOperationParam` | `diff` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchCreateFileOperationParam` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchCreateFileOperationParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchDeleteFileOperation` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchDeleteFileOperation` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchDeleteFileOperationParam` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchDeleteFileOperationParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCall` | `call_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCall` | `created_by` | 否 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCall` | `id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCall` | `operation` | 是 | `ApplyPatchCreateFileOperation \| ApplyPatchDeleteFileOperation \| ApplyPatchUpdateFileOperation` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCall` | `status` | 是 | `ApplyPatchCallStatus` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCall` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallItemParam` | `call_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallItemParam` | `id` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallItemParam` | `operation` | 是 | `ApplyPatchOperationParam` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallItemParam` | `status` | 是 | `ApplyPatchCallStatusParam` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallItemParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutput` | `call_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutput` | `created_by` | 否 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutput` | `id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutput` | `output` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutput` | `status` | 是 | `ApplyPatchCallOutputStatus` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutput` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutputItemParam` | `call_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutputItemParam` | `id` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutputItemParam` | `output` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutputItemParam` | `status` | 是 | `ApplyPatchCallOutputStatusParam` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolCallOutputItemParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchToolParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchUpdateFileOperation` | `diff` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchUpdateFileOperation` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchUpdateFileOperation` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchUpdateFileOperationParam` | `diff` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchUpdateFileOperationParam` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApplyPatchUpdateFileOperationParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ApproximateLocation` | `city` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ApproximateLocation` | `country` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ApproximateLocation` | `region` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ApproximateLocation` | `timezone` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ApproximateLocation` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `AutoCodeInterpreterToolParam` | `file_ids` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `AutoCodeInterpreterToolParam` | `memory_limit` | 否 | `ContainerMemoryLimit \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `AutoCodeInterpreterToolParam` | `network_policy` | 否 | `ContainerNetworkPolicyDisabledParam \| ContainerNetworkPolicyAllowlistParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `AutoCodeInterpreterToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionAllowedTools` | `mode` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionAllowedTools` | `tools` | 是 | `array` | openai:chat standard | native | mapped | mapped | function tools map; unsupported tool kinds fail closed | -| OpenAI | `ChatCompletionAllowedToolsChoice` | `allowed_tools` | 是 | `ChatCompletionAllowedTools` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionAllowedToolsChoice` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionFunctionCallOption` | `name` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionFunctions` | `description` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionFunctions` | `name` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionFunctions` | `parameters` | 否 | `FunctionParameters` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageCustomToolCall` | `custom` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageCustomToolCall` | `id` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageCustomToolCall` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCall` | `function` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCall` | `id` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCall` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCallChunk` | `function` | 否 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCallChunk` | `id` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCallChunk` | `index` | 是 | `integer` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionMessageToolCallChunk` | `type` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionNamedToolChoice` | `function` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionNamedToolChoice` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionNamedToolChoiceCustom` | `custom` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionNamedToolChoiceCustom` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `audio` | 否 | `object \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `content` | 否 | `string \| array \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `function_call` | 否 | `object \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `name` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `refusal` | 否 | `string \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestAssistantMessage` | `tool_calls` | 否 | `ChatCompletionMessageToolCalls` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestDeveloperMessage` | `content` | 是 | `string \| array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestDeveloperMessage` | `name` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestDeveloperMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestFunctionMessage` | `content` | 是 | `string \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestFunctionMessage` | `name` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestFunctionMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartAudio` | `input_audio` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartAudio` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartFile` | `file` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartFile` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartImage` | `image_url` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartImage` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartRefusal` | `refusal` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartRefusal` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartText` | `text` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestMessageContentPartText` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestSystemMessage` | `content` | 是 | `string \| array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestSystemMessage` | `name` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestSystemMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestToolMessage` | `content` | 是 | `string \| array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestToolMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestToolMessage` | `tool_call_id` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestUserMessage` | `content` | 是 | `string \| array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestUserMessage` | `name` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionRequestUserMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionResponseMessage` | `annotations` | 否 | `array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionResponseMessage` | `audio` | 否 | `object \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionResponseMessage` | `content` | 是 | `string \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionResponseMessage` | `function_call` | 否 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `ChatCompletionResponseMessage` | `refusal` | 是 | `string \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionResponseMessage` | `role` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionResponseMessage` | `tool_calls` | 否 | `ChatCompletionMessageToolCalls` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionStreamResponseDelta` | `content` | 否 | `string \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `ChatCompletionStreamResponseDelta` | `function_call` | 否 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `ChatCompletionStreamResponseDelta` | `refusal` | 否 | `string \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `ChatCompletionStreamResponseDelta` | `role` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionStreamResponseDelta` | `tool_calls` | 否 | `array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionTokenLogprob` | `bytes` | 是 | `array \| null` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionTokenLogprob` | `logprob` | 是 | `number` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionTokenLogprob` | `token` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionTokenLogprob` | `top_logprobs` | 是 | `array` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `ChatCompletionTool` | `function` | 是 | `FunctionObject` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ChatCompletionTool` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ClickParam` | `button` | 是 | `ClickButtonType` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ClickParam` | `keys` | 否 | `array \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ClickParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ClickParam` | `x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ClickParam` | `y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CodeInterpreterOutputImage` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterOutputImage` | `url` | 是 | `string(uri)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterOutputLogs` | `logs` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterOutputLogs` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterTool` | `container` | 是 | `string \| AutoCodeInterpreterToolParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterToolCall` | `code` | 是 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterToolCall` | `container_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterToolCall` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterToolCall` | `outputs` | 是 | `array \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterToolCall` | `status` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CodeInterpreterToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompactResource` | `created_at` | 是 | `integer(unixtime)` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResource` | `id` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResource` | `object` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResource` | `output` | 是 | `array` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResource` | `usage` | 是 | `ResponseUsage` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `input` | 否 | `string \| array \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `instructions` | 否 | `string \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `model` | 是 | `ModelIdsCompaction` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `previous_response_id` | 否 | `string \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `prompt_cache_key` | 否 | `string \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `prompt_cache_retention` | 否 | `PromptCacheRetentionEnum \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactResponseMethodPublicBody` | `service_tier` | 否 | `ServiceTierEnum \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionBody` | `created_by` | 否 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionBody` | `encrypted_content` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionBody` | `id` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionBody` | `type` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionSummaryItemParam` | `encrypted_content` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionSummaryItemParam` | `id` | 否 | `string \| null` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionSummaryItemParam` | `type` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CompactionTriggerItemParam` | `type` | 是 | `string` | openai:responses:compact native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ComparisonFilter` | `key` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComparisonFilter` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComparisonFilter` | `value` | 是 | `string \| number \| boolean \| array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompletionUsage` | `completion_tokens` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompletionUsage` | `completion_tokens_details` | 否 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompletionUsage` | `prompt_tokens` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompletionUsage` | `prompt_tokens_details` | 否 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompletionUsage` | `total_tokens` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompoundFilter` | `filters` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CompoundFilter` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallOutputItemParam` | `acknowledged_safety_checks` | 否 | `array \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallOutputItemParam` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallOutputItemParam` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallOutputItemParam` | `output` | 是 | `ComputerScreenshotImage` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallOutputItemParam` | `status` | 否 | `FunctionCallItemStatus \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallOutputItemParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallSafetyCheckParam` | `code` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallSafetyCheckParam` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerCallSafetyCheckParam` | `message` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotContent` | `detail` | 是 | `ImageDetail` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotContent` | `file_id` | 是 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotContent` | `image_url` | 是 | `string(uri) \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotImage` | `file_id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotImage` | `image_url` | 否 | `string(uri)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerScreenshotImage` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `action` | 否 | `ComputerAction` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `actions` | 否 | `ComputerActionList` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `pending_safety_checks` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `status` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutput` | `acknowledged_safety_checks` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutput` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutput` | `id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutput` | `output` | 是 | `ComputerScreenshotImage` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutput` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutput` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `acknowledged_safety_checks` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `output` | 是 | `ComputerScreenshotImage` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `status` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerToolCallOutputResource` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerUsePreviewTool` | `display_height` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerUsePreviewTool` | `display_width` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerUsePreviewTool` | `environment` | 是 | `ComputerEnvironment` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ComputerUsePreviewTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerAutoParam` | `file_ids` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerAutoParam` | `memory_limit` | 否 | `ContainerMemoryLimit \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerAutoParam` | `network_policy` | 否 | `ContainerNetworkPolicyDisabledParam \| ContainerNetworkPolicyAllowlistParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerAutoParam` | `skills` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerAutoParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerFileCitationBody` | `container_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerFileCitationBody` | `end_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerFileCitationBody` | `file_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerFileCitationBody` | `filename` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerFileCitationBody` | `start_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerFileCitationBody` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyAllowlistParam` | `allowed_domains` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyAllowlistParam` | `domain_secrets` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyAllowlistParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyDisabledParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyDomainSecretParam` | `domain` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyDomainSecretParam` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerNetworkPolicyDomainSecretParam` | `value` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerReferenceParam` | `container_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerReferenceParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerReferenceResource` | `container_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContainerReferenceResource` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContextManagementParam` | `compact_threshold` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ContextManagementParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Conversation-2` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ConversationParam-2` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CoordParam` | `x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CoordParam` | `y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateChatCompletionRequest` | `audio` | 否 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `frequency_penalty` | 否 | `number` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `function_call` | 否 | `string \| ChatCompletionFunctionCallOption` | openai:chat standard | native | extension-preserved | lossy-blocked | Chat-only or provider-specific field has no audited lossless target equivalent | -| OpenAI | `CreateChatCompletionRequest` | `functions` | 否 | `array` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `logit_bias` | 否 | `object/map` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `logprobs` | 否 | `boolean` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `max_completion_tokens` | 否 | `integer` | openai:chat standard | native | mapped | mapped | maps to canonical max_tokens; target-specific field names | -| OpenAI | `CreateChatCompletionRequest` | `max_tokens` | 否 | `integer` | openai:chat standard | native | mapped | mapped | maps to canonical max_tokens; target-specific field names | -| OpenAI | `CreateChatCompletionRequest` | `messages` | 是 | `array` | openai:chat standard | native | mapped | mapped | messages/content/tool ids map through canonical messages | -| OpenAI | `CreateChatCompletionRequest` | `metadata` | 否 | `Metadata` | openai:chat standard | native | native | native | metadata maps where target has metadata | -| OpenAI | `CreateChatCompletionRequest` | `modalities` | 否 | `ResponseModalities` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `model` | 是 | `ModelIdsShared` | openai:chat standard | native | native | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `CreateChatCompletionRequest` | `n` | 否 | `integer` | openai:chat standard | native | mapped | lossy-blocked | maps to Gemini candidateCount; blocked for Responses/Claude | -| OpenAI | `CreateChatCompletionRequest` | `parallel_tool_calls` | 否 | `ParallelToolCalls` | openai:chat standard | native | mapped | mapped | maps directly or inverse disable_parallel_tool_use | -| OpenAI | `CreateChatCompletionRequest` | `prediction` | 否 | `PredictionContent` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `presence_penalty` | 否 | `number` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `prompt_cache_key` | 否 | `string` | openai:chat standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateChatCompletionRequest` | `prompt_cache_retention` | 否 | `string \| null` | openai:chat standard | native | mapped | mapped | Chat request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateChatCompletionRequest` | `reasoning_effort` | 否 | `ReasoningEffort` | openai:chat standard | native | mapped | mapped | provider enum validated and mapped to target reasoning/thinking | -| OpenAI | `CreateChatCompletionRequest` | `response_format` | 否 | `ResponseFormatText \| ResponseFormatJsonSchema \| ResponseFormatJsonObject` | openai:chat standard | native | mapped | mapped | Chat request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateChatCompletionRequest` | `safety_identifier` | 否 | `string` | openai:chat standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateChatCompletionRequest` | `seed` | 否 | `integer` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `service_tier` | 否 | `ServiceTier` | openai:chat standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateChatCompletionRequest` | `stop` | 否 | `StopConfiguration` | openai:chat standard | native | mapped | lossy-blocked | maps to Claude/Gemini stop sequences; blocked for Responses | -| OpenAI | `CreateChatCompletionRequest` | `store` | 否 | `boolean` | openai:chat standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateChatCompletionRequest` | `stream` | 否 | `boolean` | openai:chat standard | native | extension-preserved | mapped | same-format runtime passthrough; target stream policy is transport-owned | -| OpenAI | `CreateChatCompletionRequest` | `stream_options` | 否 | `ChatCompletionStreamOptions` | openai:chat standard | native | extension-preserved | lossy-blocked | Chat stream_options has no Responses/Claude/Gemini request equivalent | -| OpenAI | `CreateChatCompletionRequest` | `temperature` | 否 | `number \| null` | openai:chat standard | native | mapped | mapped | Chat request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateChatCompletionRequest` | `tool_choice` | 否 | `ChatCompletionToolChoiceOption` | openai:chat standard | native | mapped | mapped | tool choice enum/name style maps provider-specifically | -| OpenAI | `CreateChatCompletionRequest` | `tools` | 否 | `array` | openai:chat standard | native | mapped | mapped | Chat request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateChatCompletionRequest` | `top_logprobs` | 否 | `integer \| null` | openai:chat standard | native | mapped | mapped | Chat request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateChatCompletionRequest` | `top_p` | 否 | `number \| null` | openai:chat standard | native | mapped | mapped | Chat request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateChatCompletionRequest` | `user` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | official Chat field has no audited lossless target equivalent outside same-format/OpenAI-compatible paths | -| OpenAI | `CreateChatCompletionRequest` | `verbosity` | 否 | `Verbosity` | openai:chat standard | native | mapped | mapped | maps to Responses text.verbosity; provider-only elsewhere | -| OpenAI | `CreateChatCompletionRequest` | `web_search_options` | 否 | `object` | openai:chat standard | native | extension-preserved | mapped | maps to Gemini googleSearch; blocked where target has no equivalent | -| OpenAI | `CreateChatCompletionResponse` | `choices` | 是 | `array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionResponse` | `created` | 是 | `integer(unixtime)` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionResponse` | `id` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionResponse` | `model` | 是 | `string` | openai:chat standard | native | native | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `CreateChatCompletionResponse` | `object` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionResponse` | `service_tier` | 否 | `ServiceTier` | openai:chat standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateChatCompletionResponse` | `system_fingerprint` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionResponse` | `usage` | 否 | `CompletionUsage` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionStreamResponse` | `choices` | 是 | `array` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionStreamResponse` | `created` | 是 | `integer(unixtime)` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionStreamResponse` | `id` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionStreamResponse` | `model` | 是 | `string` | openai:chat standard | native | native | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `CreateChatCompletionStreamResponse` | `object` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionStreamResponse` | `service_tier` | 否 | `ServiceTier` | openai:chat standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateChatCompletionStreamResponse` | `system_fingerprint` | 否 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateChatCompletionStreamResponse` | `usage` | 否 | `CompletionUsage` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CreateEmbeddingRequest` | `dimensions` | 否 | `integer` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingRequest` | `encoding_format` | 否 | `string` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingRequest` | `input` | 是 | `string \| array \| array \| array>` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingRequest` | `model` | 是 | `string \| string` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingRequest` | `user` | 否 | `string` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingResponse` | `data` | 是 | `array` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingResponse` | `model` | 是 | `string` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingResponse` | `object` | 是 | `string` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateEmbeddingResponse` | `usage` | 是 | `object` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `CreateImageEditRequest` | `background` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `image` | 是 | `string(binary) \| array` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `input_fidelity` | 否 | `InputFidelity \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `mask` | 否 | `string(binary)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `model` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `n` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `output_compression` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `output_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `partial_images` | 否 | `PartialImages` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `prompt` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `quality` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `response_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `size` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `stream` | 否 | `boolean` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageEditRequest` | `user` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `background` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `model` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `moderation` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `n` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `output_compression` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `output_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `partial_images` | 否 | `PartialImages` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `prompt` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `quality` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `response_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `size` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `stream` | 否 | `boolean` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `style` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageRequest` | `user` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageVariationRequest` | `image` | 是 | `string(binary)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageVariationRequest` | `model` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageVariationRequest` | `n` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageVariationRequest` | `response_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageVariationRequest` | `size` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateImageVariationRequest` | `user` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `CreateModelResponseProperties` | `metadata` | 否 | `Metadata` | openai:responses standard | native | native | native | metadata maps where target has metadata | -| OpenAI | `CreateModelResponseProperties` | `prompt_cache_key` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `prompt_cache_retention` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `safety_identifier` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `service_tier` | 否 | `ServiceTier` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `temperature` | 否 | `number \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `top_logprobs` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `top_p` | 否 | `number \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateModelResponseProperties` | `user` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `background` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `context_management` | 否 | `array \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `conversation` | 否 | `ConversationParam \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `include` | 否 | `array \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `input` | 否 | `InputParam` | openai:responses standard | native | mapped | mapped | maps to canonical messages/content/tool I/O | -| OpenAI | `CreateResponse` | `instructions` | 否 | `string \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `max_output_tokens` | 否 | `integer \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `max_tool_calls` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `metadata` | 否 | `Metadata` | openai:responses standard | native | native | native | metadata maps where target has metadata | -| OpenAI | `CreateResponse` | `model` | 否 | `ModelIdsResponses` | openai:responses standard | native | native | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `CreateResponse` | `parallel_tool_calls` | 否 | `boolean \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `previous_response_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `prompt` | 否 | `Prompt` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `prompt_cache_key` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateResponse` | `prompt_cache_retention` | 否 | `string \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `reasoning` | 否 | `Reasoning \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `safety_identifier` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateResponse` | `service_tier` | 否 | `ServiceTier` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `CreateResponse` | `store` | 否 | `boolean \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `stream` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | target stream policy is transport-owned; cross-format conversion does not emit provider stream flags | -| OpenAI | `CreateResponse` | `stream_options` | 否 | `ResponseStreamOptions` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `temperature` | 否 | `number \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `text` | 否 | `ResponseTextParam` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `CreateResponse` | `tool_choice` | 否 | `ToolChoiceParam` | openai:responses standard | native | mapped | mapped | tool choice enum/name style maps provider-specifically | -| OpenAI | `CreateResponse` | `tools` | 否 | `ToolsArray` | openai:responses standard | native | mapped | mapped | function tools map; function-only namespaces expand to reversible Chat aliases with child description/schema/strict preserved; ambiguous or unsupported namespace/custom/built-in tools fail closed unless the target supports an equivalent | -| OpenAI | `CreateResponse` | `top_logprobs` | 否 | `integer \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `top_p` | 否 | `number \| null` | openai:responses standard | native | mapped | mapped | Responses request field maps provider-specifically; target-incompatible cases fail closed | -| OpenAI | `CreateResponse` | `truncation` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CreateResponse` | `user` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `CustomGrammarFormatParam` | `definition` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomGrammarFormatParam` | `syntax` | 是 | `GrammarSyntax1` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomGrammarFormatParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomTextFormatParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCall` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCall` | `id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCall` | `input` | 是 | `string` | openai:responses standard | native | mapped | mapped | maps to canonical messages/content/tool I/O | -| OpenAI | `CustomToolCall` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCall` | `namespace` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutput` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutput` | `id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutput` | `output` | 是 | `string \| array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutput` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutputResource` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutputResource` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutputResource` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutputResource` | `output` | 是 | `string \| array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutputResource` | `status` | 是 | `FunctionCallOutputStatusEnum` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolCallOutputResource` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolChatCompletions` | `custom` | 是 | `object` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolChatCompletions` | `type` | 是 | `string` | openai:chat standard | native | extension-preserved | lossy-blocked | nested Chat field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolParam` | `defer_loading` | 否 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolParam` | `description` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolParam` | `format` | 否 | `CustomTextFormatParam \| CustomGrammarFormatParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolParam` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `CustomToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `DoubleClickAction` | `keys` | 是 | `array \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `DoubleClickAction` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `DoubleClickAction` | `x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `DoubleClickAction` | `y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `DragParam` | `keys` | 否 | `array \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `DragParam` | `path` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `DragParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EasyInputMessage` | `content` | 是 | `string \| InputMessageContentList` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `EasyInputMessage` | `phase` | 否 | `MessagePhase \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `EasyInputMessage` | `role` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `EasyInputMessage` | `type` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `EditImageBodyJsonParam` | `background` | 否 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `images` | 是 | `array` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `input_fidelity` | 否 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `mask` | 否 | `ImageRefParam` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `model` | 否 | `string \| string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `moderation` | 否 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `n` | 否 | `integer \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `output_compression` | 否 | `integer \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `output_format` | 否 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `partial_images` | 否 | `PartialImages` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `prompt` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `quality` | 否 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `size` | 否 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `stream` | 否 | `boolean \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `EditImageBodyJsonParam` | `user` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `Embedding` | `embedding` | 是 | `array` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `Embedding` | `index` | 是 | `integer` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `Embedding` | `object` | 是 | `string` | openai:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| OpenAI | `FileCitationBody` | `file_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FileCitationBody` | `filename` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FileCitationBody` | `index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FileCitationBody` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FilePath` | `file_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FilePath` | `index` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FilePath` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchTool` | `filters` | 否 | `Filters \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchTool` | `max_num_results` | 否 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchTool` | `ranking_options` | 否 | `RankingOptions` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchTool` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchTool` | `vector_store_ids` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchToolCall` | `id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchToolCall` | `queries` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchToolCall` | `results` | 否 | `array \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchToolCall` | `status` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FileSearchToolCall` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `FunctionCallOutputItemParam` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionCallOutputItemParam` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionCallOutputItemParam` | `output` | 是 | `string \| array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionCallOutputItemParam` | `status` | 否 | `FunctionCallItemStatus \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionCallOutputItemParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionObject` | `description` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionObject` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionObject` | `parameters` | 否 | `FunctionParameters` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionObject` | `strict` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellAction` | `commands` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellAction` | `max_output_length` | 是 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellAction` | `timeout_ms` | 是 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellActionParam` | `commands` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellActionParam` | `max_output_length` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellActionParam` | `timeout_ms` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `action` | 是 | `FunctionShellAction` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `environment` | 是 | `LocalEnvironmentResource \| ContainerReferenceResource \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `status` | 是 | `FunctionShellCallStatus` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallItemParam` | `action` | 是 | `FunctionShellActionParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallItemParam` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallItemParam` | `environment` | 否 | `LocalEnvironmentParam \| ContainerReferenceParam \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallItemParam` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallItemParam` | `status` | 否 | `FunctionShellCallItemStatus \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallItemParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `max_output_length` | 是 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `output` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `status` | 是 | `FunctionShellCallOutputStatusEnum` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutput` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContent` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContent` | `outcome` | 是 | `FunctionShellCallOutputTimeoutOutcome \| FunctionShellCallOutputExitOutcome` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContent` | `stderr` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContent` | `stdout` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContentParam` | `outcome` | 是 | `FunctionShellCallOutputOutcomeParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContentParam` | `stderr` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputContentParam` | `stdout` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputExitOutcome` | `exit_code` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputExitOutcome` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputExitOutcomeParam` | `exit_code` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputExitOutcomeParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputItemParam` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputItemParam` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputItemParam` | `max_output_length` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputItemParam` | `output` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputItemParam` | `status` | 否 | `FunctionShellCallItemStatus \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputItemParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputTimeoutOutcome` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellCallOutputTimeoutOutcomeParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellToolParam` | `environment` | 否 | `ContainerAutoParam \| LocalEnvironmentParam \| ContainerReferenceParam \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionShellToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionTool` | `defer_loading` | 否 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionTool` | `description` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionTool` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionTool` | `parameters` | 是 | `object/map \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionTool` | `strict` | 是 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `arguments` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `namespace` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | Responses→Chat history and Chat→Responses sync/stream responses use the reversible alias derived from the original namespace definition; missing, ambiguous, or unsupported mappings fail closed | -| OpenAI | `FunctionToolCall` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutput` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutput` | `id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutput` | `output` | 是 | `string \| array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutput` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutput` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutputResource` | `call_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutputResource` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutputResource` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutputResource` | `output` | 是 | `string \| array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutputResource` | `status` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolCallOutputResource` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolParam` | `defer_loading` | 否 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolParam` | `description` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolParam` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolParam` | `parameters` | 否 | `EmptyModelParam \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolParam` | `strict` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `FunctionToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `HybridSearchOptions` | `embedding_weight` | 是 | `number` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `HybridSearchOptions` | `text_weight` | 是 | `number` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `Image` | `b64_json` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `Image` | `revised_prompt` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `Image` | `url` | 否 | `string(uri)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `b64_json` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `background` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `created_at` | 是 | `integer(unixtime)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `output_format` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `quality` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `size` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `type` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditCompletedEvent` | `usage` | 是 | `ImagesUsage` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `b64_json` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `background` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `created_at` | 是 | `integer(unixtime)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `output_format` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `partial_image_index` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `quality` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `size` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageEditPartialImageEvent` | `type` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `b64_json` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `background` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `created_at` | 是 | `integer(unixtime)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `output_format` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `quality` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `size` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `type` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenCompletedEvent` | `usage` | 是 | `ImagesUsage` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenInputUsageDetails` | `image_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenInputUsageDetails` | `text_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenOutputTokensDetails` | `image_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenOutputTokensDetails` | `text_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `b64_json` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `background` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `created_at` | 是 | `integer(unixtime)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `output_format` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `partial_image_index` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `quality` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `size` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenPartialImageEvent` | `type` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `action` | 否 | `ImageGenActionEnum` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `background` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `input_fidelity` | 否 | `InputFidelity \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `input_image_mask` | 否 | `object` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `model` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `moderation` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `output_compression` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `output_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `partial_images` | 否 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `quality` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `size` | 否 | `string \| string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenTool` | `type` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenToolCall` | `id` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenToolCall` | `result` | 是 | `string \| null` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenToolCall` | `status` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenToolCall` | `type` | 是 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenUsage` | `input_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenUsage` | `input_tokens_details` | 是 | `ImageGenInputUsageDetails` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenUsage` | `output_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenUsage` | `output_tokens_details` | 否 | `ImageGenOutputTokensDetails` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageGenUsage` | `total_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageRefParam` | `file_id` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImageRefParam` | `image_url` | 否 | `string(uri)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `background` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `created` | 是 | `integer(unixtime)` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `data` | 否 | `array` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `output_format` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `quality` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `size` | 否 | `string` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesResponse` | `usage` | 否 | `ImageGenUsage` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesUsage` | `input_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesUsage` | `input_tokens_details` | 是 | `object` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesUsage` | `output_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ImagesUsage` | `total_tokens` | 是 | `integer` | openai:image native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillParam` | `description` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillParam` | `name` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillParam` | `source` | 是 | `InlineSkillSourceParam` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillSourceParam` | `data` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillSourceParam` | `media_type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InlineSkillSourceParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `InputFileContent` | `detail` | 否 | `FileInputDetail` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContent` | `file_data` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContent` | `file_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContent` | `file_url` | 否 | `string(uri)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContent` | `filename` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContentParam` | `detail` | 否 | `FileDetailEnum` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContentParam` | `file_data` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContentParam` | `file_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContentParam` | `file_url` | 否 | `string(uri) \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContentParam` | `filename` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputFileContentParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContent` | `detail` | 是 | `ImageDetail` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContent` | `file_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContent` | `image_url` | 否 | `string(uri) \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContentParamAutoParam` | `detail` | 否 | `DetailEnum \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContentParamAutoParam` | `file_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContentParamAutoParam` | `image_url` | 否 | `string(uri) \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputImageContentParamAutoParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputMessage` | `content` | 是 | `InputMessageContentList` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputMessage` | `role` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputMessage` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputMessage` | `type` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputTextContent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `InputTextContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `InputTextContentParam` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `InputTextContentParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ItemReferenceParam` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ItemReferenceParam` | `type` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `KeyPressAction` | `keys` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `KeyPressAction` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalEnvironmentParam` | `skills` | 否 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalEnvironmentParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalEnvironmentResource` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellExecAction` | `command` | 是 | `array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellExecAction` | `env` | 是 | `object/map` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellExecAction` | `timeout_ms` | 否 | `integer \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellExecAction` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellExecAction` | `user` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellExecAction` | `working_directory` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCall` | `action` | 是 | `LocalShellExecAction` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCall` | `call_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCall` | `id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCall` | `status` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCall` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCallOutput` | `id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCallOutput` | `output` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCallOutput` | `status` | 否 | `string \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolCallOutput` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalShellToolParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalSkillParam` | `description` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalSkillParam` | `name` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LocalSkillParam` | `path` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `LogProb` | `bytes` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `LogProb` | `logprob` | 是 | `number` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `LogProb` | `token` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `LogProb` | `top_logprobs` | 是 | `array` | openai:responses standard | native | native | lossy-blocked | OpenAI-family only; blocked to Claude/Gemini | -| OpenAI | `MCPApprovalRequest` | `arguments` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalRequest` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalRequest` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalRequest` | `server_label` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalRequest` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponse` | `approval_request_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponse` | `approve` | 是 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponse` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponse` | `reason` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponse` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponseResource` | `approval_request_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponseResource` | `approve` | 是 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponseResource` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponseResource` | `reason` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPApprovalResponseResource` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListTools` | `error` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListTools` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListTools` | `server_label` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListTools` | `tools` | 是 | `array` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | -| OpenAI | `MCPListTools` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListToolsTool` | `annotations` | 否 | `object \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListToolsTool` | `description` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListToolsTool` | `input_schema` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPListToolsTool` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `allowed_tools` | 否 | `array \| MCPToolFilter \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `authorization` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `connector_id` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `defer_loading` | 否 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `headers` | 否 | `object/map \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `require_approval` | 否 | `object \| string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `server_description` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `server_label` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `server_url` | 否 | `string(uri)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `approval_request_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `arguments` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `error` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `output` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `server_label` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `status` | 否 | `MCPToolCallStatus` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolFilter` | `read_only` | 否 | `boolean` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `MCPToolFilter` | `tool_names` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Message` | `content` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Message` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Message` | `phase` | 否 | `MessagePhase-2 \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Message` | `role` | 是 | `MessageRole` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Message` | `status` | 是 | `MessageStatus` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Message` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ModelResponseProperties` | `metadata` | 否 | `Metadata` | openai:responses standard | native | native | native | metadata maps where target has metadata | -| OpenAI | `ModelResponseProperties` | `prompt_cache_key` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `prompt_cache_retention` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `safety_identifier` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `service_tier` | 否 | `ServiceTier` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `temperature` | 否 | `number \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `top_logprobs` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `top_p` | 否 | `number \| null` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `ModelResponseProperties` | `user` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `MoveParam` | `keys` | 否 | `array \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `MoveParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `MoveParam` | `x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `MoveParam` | `y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `NamespaceToolParam` | `description` | 是 | `string` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | Responses→Chat expands function-only namespaces into reversible aliases and preserves each child definition; unsupported children or unknown fields fail closed | -| OpenAI | `NamespaceToolParam` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | namespace identity is retained in the request alias map and restored on sync/stream Responses function calls; missing or ambiguous mappings fail closed | -| OpenAI | `NamespaceToolParam` | `tools` | 是 | `array` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | function children expand to Chat function definitions with description/schema/strict preserved; custom children and unrepresentable fields fail closed | -| OpenAI | `NamespaceToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | mapped/lossy-blocked | namespace containers map only through the audited reversible Responses→Chat adapter; other cross-format targets fail closed | -| OpenAI | `OutputMessage` | `content` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputMessage` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputMessage` | `phase` | 否 | `MessagePhase \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputMessage` | `role` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputMessage` | `status` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputMessage` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputTextContent` | `annotations` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputTextContent` | `logprobs` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `OutputTextContent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `OutputTextContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `PredictionContent` | `content` | 是 | `string \| array` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `PredictionContent` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `RankingOptions` | `hybrid_search` | 否 | `HybridSearchOptions` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `RankingOptions` | `ranker` | 否 | `RankerVersionType` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `RankingOptions` | `score_threshold` | 否 | `number` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `Reasoning` | `effort` | 否 | `ReasoningEffort` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Reasoning` | `generate_summary` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Reasoning` | `summary` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningItem` | `content` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningItem` | `encrypted_content` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningItem` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningItem` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningItem` | `summary` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningItem` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ReasoningTextContent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `ReasoningTextContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `RefusalContent` | `refusal` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `RefusalContent` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `Response` | `background` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `completed_at` | 否 | `number(unixtime) \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `conversation` | 否 | `Conversation-2 \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `created_at` | 是 | `number(unixtime)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `error` | 是 | `ResponseError` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `incomplete_details` | 是 | `object \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `instructions` | 是 | `string \| array \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `max_output_tokens` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `max_tool_calls` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `metadata` | 是 | `Metadata` | openai:responses standard | native | native | native | metadata maps where target has metadata | -| OpenAI | `Response` | `model` | 是 | `ModelIdsResponses` | openai:responses standard | native | native | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `Response` | `object` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `output` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `output_text` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `parallel_tool_calls` | 是 | `boolean` | openai:responses standard | native | native | mapped | maps directly or inverse disable_parallel_tool_use | -| OpenAI | `Response` | `previous_response_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `prompt` | 否 | `Prompt` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `Response` | `prompt_cache_key` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `Response` | `prompt_cache_retention` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `reasoning` | 否 | `Reasoning \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `safety_identifier` | 否 | `string` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `Response` | `service_tier` | 否 | `ServiceTier` | openai:responses standard | native | extension-preserved | mapped | OpenAI-family only; blocked to non-OpenAI targets | -| OpenAI | `Response` | `status` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `temperature` | 是 | `number \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `text` | 否 | `ResponseTextParam` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `Response` | `tool_choice` | 是 | `ToolChoiceParam` | openai:responses standard | native | mapped | mapped | tool choice enum/name style maps provider-specifically | -| OpenAI | `Response` | `tools` | 是 | `ToolsArray` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | -| OpenAI | `Response` | `top_logprobs` | 否 | `integer \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `top_p` | 是 | `number \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `truncation` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `usage` | 否 | `ResponseUsage` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `Response` | `user` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `ResponseAudioDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioTranscriptDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioTranscriptDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioTranscriptDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioTranscriptDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseAudioTranscriptDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDoneEvent` | `code` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCodeDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCompletedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCompletedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInProgressEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInProgressEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInterpretingEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInterpretingEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInterpretingEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCodeInterpreterCallInterpretingEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCompletedEvent` | `response` | 是 | `Response` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartAddedEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartAddedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartAddedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartAddedEvent` | `part` | 是 | `OutputContent` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartAddedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartAddedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartDoneEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartDoneEvent` | `part` | 是 | `OutputContent` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseContentPartDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCreatedEvent` | `response` | 是 | `Response` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCreatedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCreatedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDoneEvent` | `input` | 是 | `string` | openai:responses standard | native | mapped | mapped | maps to canonical messages/content/tool I/O | -| OpenAI | `ResponseCustomToolCallInputDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseCustomToolCallInputDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseErrorEvent` | `code` | 是 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseErrorEvent` | `message` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseErrorEvent` | `param` | 是 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseErrorEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseErrorEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFailedEvent` | `response` | 是 | `Response` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFailedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFailedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallCompletedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallCompletedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallInProgressEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallInProgressEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallSearchingEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallSearchingEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallSearchingEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFileSearchCallSearchingEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFormatJsonObject` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFormatJsonSchema` | `json_schema` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFormatJsonSchema` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFormatText` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDoneEvent` | `arguments` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDoneEvent` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseFunctionCallArgumentsDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallCompletedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallCompletedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallGeneratingEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallGeneratingEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallGeneratingEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallGeneratingEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallInProgressEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallInProgressEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallPartialImageEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallPartialImageEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallPartialImageEvent` | `partial_image_b64` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallPartialImageEvent` | `partial_image_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallPartialImageEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseImageGenCallPartialImageEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseInProgressEvent` | `response` | 是 | `Response` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseIncompleteEvent` | `response` | 是 | `Response` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseIncompleteEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseIncompleteEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseLogProb` | `logprob` | 是 | `number` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseLogProb` | `token` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseLogProb` | `top_logprobs` | 否 | `array` | openai:responses standard | native | native | lossy-blocked | OpenAI-family only; blocked to Claude/Gemini | -| OpenAI | `ResponseMCPCallArgumentsDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDoneEvent` | `arguments` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallArgumentsDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallCompletedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallCompletedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallFailedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallFailedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallFailedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallFailedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallInProgressEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallInProgressEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPCallInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsCompletedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsCompletedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsFailedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsFailedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsFailedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsFailedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsInProgressEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsInProgressEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseMCPListToolsInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemAddedEvent` | `item` | 是 | `OutputItem` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemAddedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemAddedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemAddedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemDoneEvent` | `item` | 是 | `OutputItem` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputItemDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `annotation` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `annotation_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseOutputTextAnnotationAddedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseProperties` | `background` | 否 | `boolean \| null` | openai:responses standard | native | mapped | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `ResponseProperties` | `max_tool_calls` | 否 | `integer \| null` | openai:responses standard | native | mapped | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `ResponseProperties` | `model` | 否 | `ModelIdsResponses` | openai:responses standard | native | native | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `ResponseProperties` | `previous_response_id` | 否 | `string \| null` | openai:responses standard | native | mapped | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `ResponseProperties` | `prompt` | 否 | `Prompt` | openai:responses standard | native | extension-preserved | lossy-blocked | Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent | -| OpenAI | `ResponseProperties` | `reasoning` | 否 | `Reasoning \| null` | openai:responses standard | native | mapped | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `ResponseProperties` | `text` | 否 | `ResponseTextParam` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `ResponseProperties` | `tool_choice` | 否 | `ToolChoiceParam` | openai:responses standard | native | mapped | mapped | tool choice enum/name style maps provider-specifically | -| OpenAI | `ResponseProperties` | `tools` | 否 | `ToolsArray` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | -| OpenAI | `ResponseProperties` | `truncation` | 否 | `string \| null` | openai:responses standard | native | mapped | mapped | native provider model field; runtime model override is transport-only | -| OpenAI | `ResponseQueuedEvent` | `response` | 是 | `Response` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseQueuedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseQueuedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartAddedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartAddedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartAddedEvent` | `part` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartAddedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartAddedEvent` | `summary_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartAddedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartDoneEvent` | `part` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartDoneEvent` | `summary_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryPartDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDeltaEvent` | `summary_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDoneEvent` | `summary_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningSummaryTextDoneEvent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `ResponseReasoningSummaryTextDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDeltaEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDoneEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseReasoningTextDoneEvent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `ResponseReasoningTextDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDeltaEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDoneEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDoneEvent` | `refusal` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseRefusalDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `delta` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `logprobs` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDeltaEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDoneEvent` | `content_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDoneEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDoneEvent` | `logprobs` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDoneEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDoneEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextDoneEvent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `ResponseTextDoneEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextParam` | `format` | 否 | `TextResponseFormatConfiguration` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseTextParam` | `verbosity` | 否 | `Verbosity` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseUsage` | `input_tokens` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseUsage` | `input_tokens_details` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseUsage` | `output_tokens` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseUsage` | `output_tokens_details` | 是 | `object` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseUsage` | `total_tokens` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallCompletedEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallCompletedEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallCompletedEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallCompletedEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallInProgressEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallInProgressEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallInProgressEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallInProgressEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallSearchingEvent` | `item_id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallSearchingEvent` | `output_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallSearchingEvent` | `sequence_number` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ResponseWebSearchCallSearchingEvent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ScreenshotParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ScrollParam` | `keys` | 否 | `array \| null` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ScrollParam` | `scroll_x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ScrollParam` | `scroll_y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ScrollParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ScrollParam` | `x` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `ScrollParam` | `y` | 是 | `integer` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `SkillReferenceParam` | `skill_id` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `SkillReferenceParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `SkillReferenceParam` | `version` | 否 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `SpecificApplyPatchParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `SpecificFunctionShellParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `SummaryTextContent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `SummaryTextContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TextContent` | `text` | 是 | `string` | openai:responses standard | native | mapped | mapped | text.format and text.verbosity map provider-specifically | -| OpenAI | `TextContent` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TextResponseFormatJsonSchema` | `description` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TextResponseFormatJsonSchema` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TextResponseFormatJsonSchema` | `schema` | 是 | `ResponseFormatJsonSchemaSchema` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TextResponseFormatJsonSchema` | `strict` | 否 | `boolean \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TextResponseFormatJsonSchema` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceAllowed` | `mode` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceAllowed` | `tools` | 是 | `array` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | -| OpenAI | `ToolChoiceAllowed` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceCustom` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceCustom` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceFunction` | `name` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceFunction` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceMCP` | `name` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceMCP` | `server_label` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceMCP` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolChoiceTypes` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `arguments` | 是 | `object/value` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `call_id` | 是 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `execution` | 是 | `ToolSearchExecutionType` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `status` | 是 | `FunctionCallStatus` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCallItemParam` | `arguments` | 是 | `EmptyModelParam` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCallItemParam` | `call_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCallItemParam` | `execution` | 否 | `ToolSearchExecutionType` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCallItemParam` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCallItemParam` | `status` | 否 | `FunctionCallItemStatus \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchCallItemParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutput` | `call_id` | 是 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutput` | `created_by` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutput` | `execution` | 是 | `ToolSearchExecutionType` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutput` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutput` | `status` | 是 | `FunctionCallOutputStatusEnum` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutput` | `tools` | 是 | `array` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | -| OpenAI | `ToolSearchOutput` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutputItemParam` | `call_id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutputItemParam` | `execution` | 否 | `ToolSearchExecutionType` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutputItemParam` | `id` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutputItemParam` | `status` | 否 | `FunctionCallItemStatus \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchOutputItemParam` | `tools` | 是 | `array` | openai:responses standard | native | mapped | mapped | function tools map; custom/built-in tools fail closed unless target supports equivalent | -| OpenAI | `ToolSearchOutputItemParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchToolParam` | `description` | 否 | `string \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchToolParam` | `execution` | 否 | `ToolSearchExecutionType` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchToolParam` | `parameters` | 否 | `EmptyModelParam \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `ToolSearchToolParam` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TopLogProb` | `bytes` | 是 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TopLogProb` | `logprob` | 是 | `number` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TopLogProb` | `token` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `TypeParam` | `text` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `TypeParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `UrlCitationBody` | `end_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `UrlCitationBody` | `start_index` | 是 | `integer` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `UrlCitationBody` | `title` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `UrlCitationBody` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `UrlCitationBody` | `url` | 是 | `string(uri)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WaitParam` | `type` | 是 | `string` | openai auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| OpenAI | `WebSearchActionFind` | `pattern` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionFind` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionFind` | `url` | 是 | `string(uri)` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionOpenPage` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionOpenPage` | `url` | 否 | `string(uri) \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionSearch` | `queries` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionSearch` | `query` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionSearch` | `sources` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchActionSearch` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchLocation` | `city` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchLocation` | `country` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchLocation` | `region` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchLocation` | `timezone` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchPreviewTool` | `search_content_types` | 否 | `array` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchPreviewTool` | `search_context_size` | 否 | `SearchContextSize` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchPreviewTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchPreviewTool` | `user_location` | 否 | `ApproximateLocation \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchTool` | `filters` | 否 | `object \| null` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchTool` | `search_context_size` | 否 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchTool` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchTool` | `user_location` | 否 | `WebSearchApproximateLocation` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchToolCall` | `action` | 是 | `WebSearchActionSearch \| WebSearchActionOpenPage \| WebSearchActionFind` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchToolCall` | `id` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchToolCall` | `status` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| OpenAI | `WebSearchToolCall` | `type` | 是 | `string` | openai:responses standard | native | extension-preserved | lossy-blocked | nested Responses field is preserved same-format; cross-format requires explicit parent mapping or fails closed | -| Claude | `Base64ImageSource` | `data` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Base64ImageSource` | `media_type` | 是 | `'image/jpeg' \| 'image/png' \| 'image/gif' \| 'image/webp'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Base64ImageSource` | `type` | 是 | `'base64'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Base64PDFSource` | `data` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Base64PDFSource` | `media_type` | 是 | `'application/pdf'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Base64PDFSource` | `type` | 是 | `'base64'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionOutputBlock` | `file_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `BashCodeExecutionOutputBlock` | `type` | 是 | `'bash_code_execution_output'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionOutputBlockParam` | `file_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `BashCodeExecutionOutputBlockParam` | `type` | 是 | `'bash_code_execution_output'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionResultBlock` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionResultBlock` | `return_code` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionResultBlock` | `stderr` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionResultBlock` | `stdout` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionResultBlock` | `type` | 是 | `'bash_code_execution_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionResultBlockParam` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionResultBlockParam` | `return_code` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionResultBlockParam` | `stderr` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionResultBlockParam` | `stdout` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionResultBlockParam` | `type` | 是 | `'bash_code_execution_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionToolResultBlock` | `content` | 是 | `BashCodeExecutionToolResultError \| BashCodeExecutionResultBlock` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionToolResultBlock` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionToolResultBlock` | `type` | 是 | `'bash_code_execution_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionToolResultBlockParam` | `content` | 是 | `BashCodeExecutionToolResultErrorParam \| BashCodeExecutionResultBlockParam` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionToolResultBlockParam` | `type` | 是 | `'bash_code_execution_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `BashCodeExecutionToolResultError` | `error_code` | 是 | `BashCodeExecutionToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionToolResultError` | `type` | 是 | `'bash_code_execution_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `BashCodeExecutionToolResultErrorParam` | `error_code` | 是 | `BashCodeExecutionToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `BashCodeExecutionToolResultErrorParam` | `type` | 是 | `'bash_code_execution_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CacheControlEphemeral` | `type` | 是 | `'ephemeral'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CacheControlEphemeral` | `ttl` | 否 | `'5m' \| '1h'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CacheCreation` | `ephemeral_1h_input_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CacheCreation` | `ephemeral_5m_input_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationCharLocation` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocation` | `document_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocation` | `document_title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocation` | `end_char_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationCharLocation` | `file_id` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocation` | `start_char_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationCharLocation` | `type` | 是 | `'char_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationCharLocationParam` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocationParam` | `document_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocationParam` | `document_title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationCharLocationParam` | `end_char_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationCharLocationParam` | `start_char_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationCharLocationParam` | `type` | 是 | `'char_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationContentBlockLocation` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocation` | `document_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocation` | `document_title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocation` | `end_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationContentBlockLocation` | `file_id` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocation` | `start_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationContentBlockLocation` | `type` | 是 | `'content_block_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationContentBlockLocationParam` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocationParam` | `document_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocationParam` | `document_title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationContentBlockLocationParam` | `end_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationContentBlockLocationParam` | `start_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationContentBlockLocationParam` | `type` | 是 | `'content_block_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationPageLocation` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocation` | `document_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocation` | `document_title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocation` | `end_page_number` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationPageLocation` | `file_id` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocation` | `start_page_number` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationPageLocation` | `type` | 是 | `'page_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationPageLocationParam` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocationParam` | `document_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocationParam` | `document_title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationPageLocationParam` | `end_page_number` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationPageLocationParam` | `start_page_number` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationPageLocationParam` | `type` | 是 | `'page_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationSearchResultLocationParam` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationSearchResultLocationParam` | `end_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationSearchResultLocationParam` | `search_result_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationSearchResultLocationParam` | `source` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationSearchResultLocationParam` | `start_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationSearchResultLocationParam` | `title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationSearchResultLocationParam` | `type` | 是 | `'search_result_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationWebSearchResultLocationParam` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationWebSearchResultLocationParam` | `encrypted_index` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationWebSearchResultLocationParam` | `title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationWebSearchResultLocationParam` | `type` | 是 | `'web_search_result_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationWebSearchResultLocationParam` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsConfig` | `enabled` | 是 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsConfigParam` | `enabled` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsDelta` | `citation` | 是 | `\| CitationCharLocation \| CitationPageLocation \| CitationContentBlockLocation \| CitationsWebSearchResultLocation \| CitationsSearchResultLocation` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsDelta` | `type` | 是 | `'citations_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationsSearchResultLocation` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationsSearchResultLocation` | `end_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsSearchResultLocation` | `search_result_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsSearchResultLocation` | `source` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationsSearchResultLocation` | `start_block_index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsSearchResultLocation` | `title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationsSearchResultLocation` | `type` | 是 | `'search_result_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationsWebSearchResultLocation` | `cited_text` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationsWebSearchResultLocation` | `encrypted_index` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CitationsWebSearchResultLocation` | `title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CitationsWebSearchResultLocation` | `type` | 是 | `'web_search_result_location'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CitationsWebSearchResultLocation` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionOutputBlock` | `file_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionOutputBlock` | `type` | 是 | `'code_execution_output'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionOutputBlockParam` | `file_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionOutputBlockParam` | `type` | 是 | `'code_execution_output'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionResultBlock` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionResultBlock` | `return_code` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionResultBlock` | `stderr` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionResultBlock` | `stdout` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionResultBlock` | `type` | 是 | `'code_execution_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionResultBlockParam` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionResultBlockParam` | `return_code` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionResultBlockParam` | `stderr` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionResultBlockParam` | `stdout` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionResultBlockParam` | `type` | 是 | `'code_execution_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20250522` | `name` | 是 | `'code_execution'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20250522` | `type` | 是 | `'code_execution_20250522'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20250522` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250522` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250522` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250522` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250825` | `name` | 是 | `'code_execution'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20250825` | `type` | 是 | `'code_execution_20250825'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20250825` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250825` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250825` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20250825` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20260120` | `name` | 是 | `'code_execution'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20260120` | `type` | 是 | `'code_execution_20260120'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionTool20260120` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20260120` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20260120` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionTool20260120` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionToolResultBlock` | `content` | 是 | `CodeExecutionToolResultBlockContent` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionToolResultBlock` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionToolResultBlock` | `type` | 是 | `'code_execution_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionToolResultBlockParam` | `content` | 是 | `CodeExecutionToolResultBlockParamContent` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionToolResultBlockParam` | `type` | 是 | `'code_execution_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `CodeExecutionToolResultError` | `error_code` | 是 | `CodeExecutionToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionToolResultError` | `type` | 是 | `'code_execution_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `CodeExecutionToolResultErrorParam` | `error_code` | 是 | `CodeExecutionToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `CodeExecutionToolResultErrorParam` | `type` | 是 | `'code_execution_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Container` | `id` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Container` | `expires_at` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ContainerUploadBlock` | `file_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ContainerUploadBlock` | `type` | 是 | `'container_upload'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ContainerUploadBlockParam` | `file_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ContainerUploadBlockParam` | `type` | 是 | `'container_upload'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ContainerUploadBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ContentBlockSource` | `content` | 是 | `string \| Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ContentBlockSource` | `type` | 是 | `'content'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `DirectCaller` | `type` | 是 | `'direct'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `DocumentBlock` | `citations` | 是 | `CitationsConfig \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `DocumentBlock` | `source` | 是 | `Base64PDFSource \| PlainTextSource` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `DocumentBlock` | `title` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `DocumentBlock` | `type` | 是 | `'document'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `DocumentBlockParam` | `source` | 是 | `Base64PDFSource \| PlainTextSource \| ContentBlockSource \| URLPDFSource` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `DocumentBlockParam` | `type` | 是 | `'document'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `DocumentBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `DocumentBlockParam` | `citations` | 否 | `CitationsConfigParam \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `DocumentBlockParam` | `context` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `DocumentBlockParam` | `title` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `EncryptedCodeExecutionResultBlock` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `EncryptedCodeExecutionResultBlock` | `encrypted_stdout` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `EncryptedCodeExecutionResultBlock` | `return_code` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `EncryptedCodeExecutionResultBlock` | `stderr` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `EncryptedCodeExecutionResultBlock` | `type` | 是 | `'encrypted_code_execution_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `EncryptedCodeExecutionResultBlockParam` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `EncryptedCodeExecutionResultBlockParam` | `encrypted_stdout` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `EncryptedCodeExecutionResultBlockParam` | `return_code` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `EncryptedCodeExecutionResultBlockParam` | `stderr` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `EncryptedCodeExecutionResultBlockParam` | `type` | 是 | `'encrypted_code_execution_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ImageBlockParam` | `source` | 是 | `Base64ImageSource \| URLImageSource` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ImageBlockParam` | `type` | 是 | `'image'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ImageBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `InputJSONDelta` | `partial_json` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `InputJSONDelta` | `type` | 是 | `'input_json_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `JSONOutputFormat` | `schema` | 是 | `{ [key: string]: unknown }` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `JSONOutputFormat` | `type` | 是 | `'json_schema'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MemoryTool20250818` | `name` | 是 | `'memory'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MemoryTool20250818` | `type` | 是 | `'memory_20250818'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MemoryTool20250818` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MemoryTool20250818` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MemoryTool20250818` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MemoryTool20250818` | `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MemoryTool20250818` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Message` | `id` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `container` | 是 | `Container \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Message` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `model` | 是 | `Model` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `role` | 是 | `'assistant'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `stop_details` | 是 | `RefusalStopDetails \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Message` | `stop_reason` | 是 | `StopReason \| null` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `stop_sequence` | 是 | `string \| null` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `type` | 是 | `'message'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Message` | `usage` | 是 | `Usage` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCountTokensParams` | `messages` | 是 | `Array` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `model` | 是 | `Model` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `output_config` | 否 | `OutputConfig` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `system` | 否 | `string \| Array` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `thinking` | 否 | `ThinkingConfigParam` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `tool_choice` | 否 | `ToolChoice` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCountTokensParams` | `tools` | 否 | `Array` | claude:messages/count_tokens native-only | native | not-in-conversion-surface | not-in-conversion-surface | count_tokens schemas are provider-native and outside canonical generation conversion | -| Claude | `MessageCreateParamsBase` | `max_tokens` | 是 | `number` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `messages` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `model` | 是 | `Model` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageCreateParamsBase` | `container` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageCreateParamsBase` | `inference_geo` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageCreateParamsBase` | `metadata` | 否 | `Metadata` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `output_config` | 否 | `OutputConfig` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `service_tier` | 否 | `'auto' \| 'standard_only'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageCreateParamsBase` | `stop_sequences` | 否 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `stream` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MessageCreateParamsBase` | `system` | 否 | `string \| Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `temperature` | 否 | `number` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `thinking` | 否 | `ThinkingConfigParam` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `tool_choice` | 否 | `ToolChoice` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `tools` | 否 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `top_k` | 否 | `number` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsBase` | `top_p` | 否 | `number` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageCreateParamsNonStreaming` | `stream` | 否 | `false` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MessageCreateParamsStreaming` | `stream` | 是 | `true` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MessageDeltaUsage` | `cache_creation_input_tokens` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageDeltaUsage` | `cache_read_input_tokens` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageDeltaUsage` | `input_tokens` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MessageDeltaUsage` | `output_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MessageDeltaUsage` | `output_tokens_details` | 是 | `OutputTokensDetails \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `MessageDeltaUsage` | `server_tool_use` | 是 | `ServerToolUsage \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MessageParam` | `content` | 是 | `string \| Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageParam` | `role` | 是 | `'user' \| 'assistant' \| 'system'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MessageTokensCount` | `input_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Metadata` | `user_id` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `MidConversationSystemBlockParam` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MidConversationSystemBlockParam` | `type` | 是 | `'mid_conv_system'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `MidConversationSystemBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `OutputConfig` | `effort` | 否 | `'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max' \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `OutputConfig` | `format` | 否 | `JSONOutputFormat \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `OutputTokensDetails` | `thinking_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `PlainTextSource` | `data` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `PlainTextSource` | `media_type` | 是 | `'text/plain'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `PlainTextSource` | `type` | 是 | `'text'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawContentBlockDeltaEvent` | `delta` | 是 | `RawContentBlockDelta` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawContentBlockDeltaEvent` | `index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawContentBlockDeltaEvent` | `type` | 是 | `'content_block_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawContentBlockStartEvent` | `content_block` | 是 | `\| TextBlock \| ThinkingBlock \| RedactedThinkingBlock \| ToolUseBlock \| ServerToolUseBlock \| WebSearchToolResultBlock \| WebFetchToolResultBlock \| CodeExecutionToolResultBlock \| BashCodeExecutionToolResultBlock \| TextEditorCodeExecutionToolResultBlock \| ToolSearchToolResultBlock \| ContainerUploadBlock` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawContentBlockStartEvent` | `index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawContentBlockStartEvent` | `type` | 是 | `'content_block_start'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawContentBlockStopEvent` | `index` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawContentBlockStopEvent` | `type` | 是 | `'content_block_stop'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawMessageDeltaEvent` | `delta` | 是 | `RawMessageDeltaEvent.Delta` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawMessageDeltaEvent` | `type` | 是 | `'message_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawMessageDeltaEvent` | `usage` | 是 | `MessageDeltaUsage` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawMessageStartEvent` | `message` | 是 | `Message` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RawMessageStartEvent` | `type` | 是 | `'message_start'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RawMessageStopEvent` | `type` | 是 | `'message_stop'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RedactedThinkingBlock` | `data` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RedactedThinkingBlock` | `type` | 是 | `'redacted_thinking'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RedactedThinkingBlockParam` | `data` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RedactedThinkingBlockParam` | `type` | 是 | `'redacted_thinking'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `RefusalStopDetails` | `category` | 是 | `'cyber' \| 'bio' \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RefusalStopDetails` | `explanation` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `RefusalStopDetails` | `type` | 是 | `'refusal'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `SearchResultBlockParam` | `content` | 是 | `Array` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `SearchResultBlockParam` | `source` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `SearchResultBlockParam` | `title` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `SearchResultBlockParam` | `type` | 是 | `'search_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `SearchResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `SearchResultBlockParam` | `citations` | 否 | `CitationsConfigParam` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ServerToolCaller` | `tool_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ServerToolCaller` | `type` | 是 | `'code_execution_20250825'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolCaller20260120` | `tool_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ServerToolCaller20260120` | `type` | 是 | `'code_execution_20260120'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUsage` | `web_fetch_requests` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ServerToolUsage` | `web_search_requests` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ServerToolUseBlock` | `id` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlock` | `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ServerToolUseBlock` | `input` | 是 | `unknown` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlock` | `name` | 是 | `\| 'web_search' \| 'web_fetch' \| 'code_execution' \| 'bash_code_execution' \| 'text_editor_code_execution' \| 'tool_search_tool_regex' \| 'tool_search_tool_bm25'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlock` | `type` | 是 | `'server_tool_use'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlockParam` | `id` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlockParam` | `input` | 是 | `unknown` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlockParam` | `name` | 是 | `\| 'web_search' \| 'web_fetch' \| 'code_execution' \| 'bash_code_execution' \| 'text_editor_code_execution' \| 'tool_search_tool_regex' \| 'tool_search_tool_bm25'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlockParam` | `type` | 是 | `'server_tool_use'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ServerToolUseBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ServerToolUseBlockParam` | `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `SignatureDelta` | `signature` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `SignatureDelta` | `type` | 是 | `'signature_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextBlock` | `citations` | 是 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `TextBlock` | `text` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextBlock` | `type` | 是 | `'text'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextBlockParam` | `text` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextBlockParam` | `type` | 是 | `'text'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `TextBlockParam` | `citations` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `TextDelta` | `text` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextDelta` | `type` | 是 | `'text_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionCreateResultBlock` | `is_file_update` | 是 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionCreateResultBlock` | `type` | 是 | `'text_editor_code_execution_create_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionCreateResultBlockParam` | `is_file_update` | 是 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionCreateResultBlockParam` | `type` | 是 | `'text_editor_code_execution_create_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlock` | `lines` | 是 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlock` | `new_lines` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlock` | `new_start` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlock` | `old_lines` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlock` | `old_start` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlock` | `type` | 是 | `'text_editor_code_execution_str_replace_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlockParam` | `type` | 是 | `'text_editor_code_execution_str_replace_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlockParam` | `lines` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlockParam` | `new_lines` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlockParam` | `new_start` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlockParam` | `old_lines` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionStrReplaceResultBlockParam` | `old_start` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlock` | `content` | 是 | `\| TextEditorCodeExecutionToolResultError \| TextEditorCodeExecutionViewResultBlock \| TextEditorCodeExecutionCreateResultBlock \| TextEditorCodeExecutionStrReplaceResultBlock` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlock` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlock` | `type` | 是 | `'text_editor_code_execution_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlockParam` | `content` | 是 | `\| TextEditorCodeExecutionToolResultErrorParam \| TextEditorCodeExecutionViewResultBlockParam \| TextEditorCodeExecutionCreateResultBlockParam \| TextEditorCodeExecutionStrReplaceResultBlockParam` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlockParam` | `type` | 是 | `'text_editor_code_execution_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `TextEditorCodeExecutionToolResultError` | `error_code` | 是 | `TextEditorCodeExecutionToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionToolResultError` | `error_message` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionToolResultError` | `type` | 是 | `'text_editor_code_execution_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionToolResultErrorParam` | `error_code` | 是 | `TextEditorCodeExecutionToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionToolResultErrorParam` | `type` | 是 | `'text_editor_code_execution_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionToolResultErrorParam` | `error_message` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlock` | `content` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlock` | `file_type` | 是 | `'text' \| 'image' \| 'pdf'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlock` | `num_lines` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlock` | `start_line` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlock` | `total_lines` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlock` | `type` | 是 | `'text_editor_code_execution_view_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlockParam` | `content` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlockParam` | `file_type` | 是 | `'text' \| 'image' \| 'pdf'` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlockParam` | `type` | 是 | `'text_editor_code_execution_view_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlockParam` | `num_lines` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlockParam` | `start_line` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `TextEditorCodeExecutionViewResultBlockParam` | `total_lines` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ThinkingBlock` | `signature` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ThinkingBlock` | `thinking` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingBlock` | `type` | 是 | `'thinking'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingBlockParam` | `signature` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ThinkingBlockParam` | `thinking` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingBlockParam` | `type` | 是 | `'thinking'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingConfigAdaptive` | `type` | 是 | `'adaptive'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingConfigAdaptive` | `display` | 否 | `'summarized' \| 'omitted' \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ThinkingConfigDisabled` | `type` | 是 | `'disabled'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingConfigEnabled` | `budget_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ThinkingConfigEnabled` | `type` | 是 | `'enabled'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingConfigEnabled` | `display` | 否 | `'summarized' \| 'omitted' \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ThinkingDelta` | `thinking` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ThinkingDelta` | `type` | 是 | `'thinking_delta'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Tool` | `input_schema` | 是 | `Tool.InputSchema` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Tool` | `name` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Tool` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Tool` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Tool` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Tool` | `description` | 否 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `Tool` | `eager_input_streaming` | 否 | `boolean \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Tool` | `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Tool` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Tool` | `type` | 否 | `'custom' \| null` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolBash20250124` | `name` | 是 | `'bash'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolBash20250124` | `type` | 是 | `'bash_20250124'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolBash20250124` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolBash20250124` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolBash20250124` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolBash20250124` | `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolBash20250124` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolChoiceAny` | `type` | 是 | `'any'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolChoiceAny` | `disable_parallel_tool_use` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolChoiceAuto` | `type` | 是 | `'auto'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolChoiceAuto` | `disable_parallel_tool_use` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolChoiceNone` | `type` | 是 | `'none'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolChoiceTool` | `name` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolChoiceTool` | `type` | 是 | `'tool'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolChoiceTool` | `disable_parallel_tool_use` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolReferenceBlock` | `tool_name` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolReferenceBlock` | `type` | 是 | `'tool_reference'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolReferenceBlockParam` | `tool_name` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolReferenceBlockParam` | `type` | 是 | `'tool_reference'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolReferenceBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolResultBlockParam` | `type` | 是 | `'tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolResultBlockParam` | `content` | 否 | `\| string \| Array< \| TextBlockParam \| ImageBlockParam \| SearchResultBlockParam \| DocumentBlockParam \| ToolReferenceBlockParam >` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolResultBlockParam` | `is_error` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolBm25_20251119` | `name` | 是 | `'tool_search_tool_bm25'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolBm25_20251119` | `type` | 是 | `'tool_search_tool_bm25_20251119' \| 'tool_search_tool_bm25'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolBm25_20251119` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolBm25_20251119` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolBm25_20251119` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolBm25_20251119` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolRegex20251119` | `name` | 是 | `'tool_search_tool_regex'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolRegex20251119` | `type` | 是 | `'tool_search_tool_regex_20251119' \| 'tool_search_tool_regex'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolRegex20251119` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolRegex20251119` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolRegex20251119` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolRegex20251119` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolResultBlock` | `content` | 是 | `ToolSearchToolResultError \| ToolSearchToolSearchResultBlock` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolResultBlock` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolResultBlock` | `type` | 是 | `'tool_search_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolResultBlockParam` | `content` | 是 | `ToolSearchToolResultErrorParam \| ToolSearchToolSearchResultBlockParam` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolResultBlockParam` | `type` | 是 | `'tool_search_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolSearchToolResultError` | `error_code` | 是 | `ToolSearchToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolResultError` | `error_message` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolResultError` | `type` | 是 | `'tool_search_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolResultErrorParam` | `error_code` | 是 | `ToolSearchToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolResultErrorParam` | `type` | 是 | `'tool_search_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolSearchResultBlock` | `tool_references` | 是 | `Array` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolSearchResultBlock` | `type` | 是 | `'tool_search_tool_search_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolSearchToolSearchResultBlockParam` | `tool_references` | 是 | `Array` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolSearchToolSearchResultBlockParam` | `type` | 是 | `'tool_search_tool_search_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250124` | `name` | 是 | `'str_replace_editor'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250124` | `type` | 是 | `'text_editor_20250124'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250124` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250124` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250124` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250124` | `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolTextEditor20250124` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250429` | `name` | 是 | `'str_replace_based_edit_tool'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250429` | `type` | 是 | `'text_editor_20250429'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250429` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250429` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250429` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250429` | `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolTextEditor20250429` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250728` | `name` | 是 | `'str_replace_based_edit_tool'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250728` | `type` | 是 | `'text_editor_20250728'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolTextEditor20250728` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250728` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250728` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolTextEditor20250728` | `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolTextEditor20250728` | `max_characters` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `ToolTextEditor20250728` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolUseBlock` | `id` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlock` | `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolUseBlock` | `input` | 是 | `unknown` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlock` | `name` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlock` | `type` | 是 | `'tool_use'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlockParam` | `id` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlockParam` | `input` | 是 | `unknown` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlockParam` | `name` | 是 | `string` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlockParam` | `type` | 是 | `'tool_use'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `ToolUseBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `ToolUseBlockParam` | `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `URLImageSource` | `type` | 是 | `'url'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `URLImageSource` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `URLPDFSource` | `type` | 是 | `'url'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `URLPDFSource` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Usage` | `cache_creation` | 是 | `CacheCreation \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Usage` | `cache_creation_input_tokens` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Usage` | `cache_read_input_tokens` | 是 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Usage` | `inference_geo` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Usage` | `input_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Usage` | `output_tokens` | 是 | `number` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Usage` | `output_tokens_details` | 是 | `OutputTokensDetails \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `Usage` | `server_tool_use` | 是 | `ServerToolUsage \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `Usage` | `service_tier` | 是 | `'standard' \| 'priority' \| 'batch' \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `UserLocation` | `type` | 是 | `'approximate'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `UserLocation` | `city` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `UserLocation` | `country` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `UserLocation` | `region` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `UserLocation` | `timezone` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchBlock` | `content` | 是 | `DocumentBlock` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchBlock` | `retrieved_at` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchBlock` | `type` | 是 | `'web_fetch_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchBlock` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchBlockParam` | `content` | 是 | `DocumentBlockParam` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchBlockParam` | `type` | 是 | `'web_fetch_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchBlockParam` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchBlockParam` | `retrieved_at` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchTool20250910` | `name` | 是 | `'web_fetch'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchTool20250910` | `type` | 是 | `'web_fetch_20250910'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchTool20250910` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `allowed_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `blocked_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `citations` | 否 | `CitationsConfigParam \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `max_content_tokens` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchTool20250910` | `max_uses` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20250910` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `name` | 是 | `'web_fetch'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchTool20260209` | `type` | 是 | `'web_fetch_20260209'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchTool20260209` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `allowed_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `blocked_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `citations` | 否 | `CitationsConfigParam \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `max_content_tokens` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchTool20260209` | `max_uses` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260209` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `name` | 是 | `'web_fetch'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchTool20260309` | `type` | 是 | `'web_fetch_20260309'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchTool20260309` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `allowed_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `blocked_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `citations` | 否 | `CitationsConfigParam \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `max_content_tokens` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchTool20260309` | `max_uses` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchTool20260309` | `use_cache` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchToolResultBlock` | `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchToolResultBlock` | `content` | 是 | `WebFetchToolResultErrorBlock \| WebFetchBlock` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchToolResultBlock` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchToolResultBlock` | `type` | 是 | `'web_fetch_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchToolResultBlockParam` | `content` | 是 | `WebFetchToolResultErrorBlockParam \| WebFetchBlockParam` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchToolResultBlockParam` | `type` | 是 | `'web_fetch_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchToolResultBlockParam` | `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebFetchToolResultErrorBlock` | `error_code` | 是 | `WebFetchToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchToolResultErrorBlock` | `type` | 是 | `'web_fetch_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebFetchToolResultErrorBlockParam` | `error_code` | 是 | `WebFetchToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebFetchToolResultErrorBlockParam` | `type` | 是 | `'web_fetch_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchResultBlock` | `encrypted_content` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchResultBlock` | `page_age` | 是 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchResultBlock` | `title` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchResultBlock` | `type` | 是 | `'web_search_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchResultBlock` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchResultBlockParam` | `encrypted_content` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchResultBlockParam` | `title` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchResultBlockParam` | `type` | 是 | `'web_search_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchResultBlockParam` | `url` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchResultBlockParam` | `page_age` | 否 | `string \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchTool20250305` | `name` | 是 | `'web_search'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchTool20250305` | `type` | 是 | `'web_search_20250305'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchTool20250305` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `allowed_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `blocked_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `max_uses` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20250305` | `user_location` | 否 | `UserLocation \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `name` | 是 | `'web_search'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchTool20260209` | `type` | 是 | `'web_search_20260209'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchTool20260209` | `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `allowed_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `blocked_domains` | 否 | `Array \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `defer_loading` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `max_uses` | 否 | `number \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `strict` | 否 | `boolean` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchTool20260209` | `user_location` | 否 | `UserLocation \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchToolRequestError` | `error_code` | 是 | `WebSearchToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchToolRequestError` | `type` | 是 | `'web_search_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchToolResultBlock` | `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchToolResultBlock` | `content` | 是 | `WebSearchToolResultBlockContent` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchToolResultBlock` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchToolResultBlock` | `type` | 是 | `'web_search_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchToolResultBlockParam` | `content` | 是 | `WebSearchToolResultBlockParamContent` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchToolResultBlockParam` | `tool_use_id` | 是 | `string` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchToolResultBlockParam` | `type` | 是 | `'web_search_tool_result'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Claude | `WebSearchToolResultBlockParam` | `cache_control` | 否 | `CacheControlEphemeral \| null` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchToolResultBlockParam` | `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent | -| Claude | `WebSearchToolResultError` | `error_code` | 是 | `WebSearchToolResultErrorCode` | claude:messages standard | native | extension-preserved | lossy-blocked | Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Claude | `WebSearchToolResultError` | `type` | 是 | `'web_search_tool_result_error'` | claude:messages standard | native | mapped | mapped/lossy-blocked | Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed | -| Gemini | `AttributionSourceId` | `groundingPassage` | 否 | `GroundingPassageId` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `AttributionSourceId` | `semanticRetrieverChunk` | 否 | `SemanticRetrieverChunk` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `AudioResponseFormat` | `bitRate` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `AudioResponseFormat` | `delivery` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `AudioResponseFormat` | `mimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `AudioResponseFormat` | `sampleRate` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `BatchEmbedContentsRequest` | `requests` | 否 | `array` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `BatchEmbedContentsResponse` | `embeddings` | 否 | `array` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `BatchEmbedContentsResponse` | `usageMetadata` | 否 | `EmbeddingUsageMetadata` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `Blob` | `data` | 否 | `string(byte)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Blob` | `mimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `avgLogprobs` | 否 | `number(double)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `citationMetadata` | 否 | `CitationMetadata` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `content` | 否 | `Content` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `finishMessage` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `finishReason` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `groundingAttributions` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `groundingMetadata` | 否 | `GroundingMetadata` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `index` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `logprobsResult` | 否 | `LogprobsResult` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `safetyRatings` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `tokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Candidate` | `urlContextMetadata` | 否 | `UrlContextMetadata` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CitationMetadata` | `citationSources` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CitationSource` | `endIndex` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CitationSource` | `license` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CitationSource` | `startIndex` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CitationSource` | `uri` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CodeExecutionResult` | `id` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CodeExecutionResult` | `outcome` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `CodeExecutionResult` | `output` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ComputerUse` | `environment` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ComputerUse` | `excludedPredefinedFunctions` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Content` | `parts` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Content` | `role` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ContentEmbedding` | `shape` | 否 | `array` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `ContentEmbedding` | `values` | 否 | `array` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `CountTokensRequest` | `contents` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CountTokensRequest` | `generateContentRequest` | 否 | `GenerateContentRequest` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CountTokensResponse` | `cacheTokensDetails` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CountTokensResponse` | `cachedContentTokenCount` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CountTokensResponse` | `promptTokensDetails` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CountTokensResponse` | `totalTokens` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CreateFileRequest` | `file` | 否 | `File` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `CreateFileResponse` | `file` | 否 | `File` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `DynamicRetrievalConfig` | `dynamicThreshold` | 否 | `number(float)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `DynamicRetrievalConfig` | `mode` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `EmbedContentConfig` | `audioTrackExtraction` | 否 | `boolean` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentConfig` | `autoTruncate` | 否 | `boolean` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentConfig` | `documentOcr` | 否 | `boolean` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentConfig` | `outputDimensionality` | 否 | `integer(int32)` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentConfig` | `taskType` | 否 | `string` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentConfig` | `title` | 否 | `string` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentRequest` | `content` | 否 | `Content` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentRequest` | `embedContentConfig` | 否 | `EmbedContentConfig` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentRequest` | `model` | 否 | `string` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentRequest` | `outputDimensionality` | 否 | `integer(int32)` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentRequest` | `taskType` | 否 | `string` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentRequest` | `title` | 否 | `string` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentResponse` | `embedding` | 否 | `ContentEmbedding` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbedContentResponse` | `usageMetadata` | 否 | `EmbeddingUsageMetadata` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbeddingUsageMetadata` | `promptTokenCount` | 否 | `integer(int32)` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `EmbeddingUsageMetadata` | `promptTokenDetails` | 否 | `array` | gemini:embedding | native | mapped | mapped/lossy-blocked | embedding field maps only within embedding targets; incompatible task/input/provider extensions fail closed | -| Gemini | `ExecutableCode` | `code` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ExecutableCode` | `id` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ExecutableCode` | `language` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `File` | `createTime` | 否 | `string(google-datetime)` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `displayName` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `downloadUri` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `error` | 否 | `Status` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `expirationTime` | 否 | `string(google-datetime)` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `mimeType` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `name` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `sha256Hash` | 否 | `string(byte)` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `sizeBytes` | 否 | `string(int64)` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `source` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `state` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `updateTime` | 否 | `string(google-datetime)` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `uri` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `File` | `videoMetadata` | 否 | `VideoFileMetadata` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `FileData` | `fileUri` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FileData` | `mimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FileSearch` | `fileSearchStoreNames` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `FileSearch` | `metadataFilter` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `FileSearch` | `topK` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `FunctionCall` | `args` | 否 | `object/map` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionCall` | `id` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionCall` | `name` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionCallingConfig` | `allowedFunctionNames` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionCallingConfig` | `mode` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `behavior` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `description` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `name` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `parameters` | 否 | `Schema` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `parametersJsonSchema` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `response` | 否 | `Schema` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionDeclaration` | `responseJsonSchema` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponse` | `id` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponse` | `name` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponse` | `parts` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponse` | `response` | 否 | `object/map` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponse` | `scheduling` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponse` | `willContinue` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponseBlob` | `data` | 否 | `string(byte)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponseBlob` | `mimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `FunctionResponsePart` | `inlineData` | 否 | `FunctionResponseBlob` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerateContentRequest` | `cachedContent` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini provider-only field has no lossless OpenAI/Claude target equivalent | -| Gemini | `GenerateContentRequest` | `contents` | 否 | `array` | gemini:generate_content standard | native | mapped | mapped | contents/parts map through canonical messages | -| Gemini | `GenerateContentRequest` | `generationConfig` | 否 | `GenerationConfig` | gemini:generate_content standard | native | mapped | mapped | supported generation fields map; unsupported nested fields fail closed | -| Gemini | `GenerateContentRequest` | `model` | 否 | `string` | gemini:generate_content standard | native | native | mapped | native provider model field or URL path model | -| Gemini | `GenerateContentRequest` | `safetySettings` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini provider-only field has no lossless OpenAI/Claude target equivalent | -| Gemini | `GenerateContentRequest` | `serviceTier` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini provider-only field has no lossless OpenAI/Claude target equivalent | -| Gemini | `GenerateContentRequest` | `store` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini provider-only field has no lossless OpenAI/Claude target equivalent | -| Gemini | `GenerateContentRequest` | `systemInstruction` | 否 | `Content` | gemini:generate_content standard | native | mapped | mapped | maps to target system/instructions | -| Gemini | `GenerateContentRequest` | `toolConfig` | 否 | `ToolConfig` | gemini:generate_content standard | native | mapped | mapped | functionCallingConfig mode and single allowedFunctionNames map | -| Gemini | `GenerateContentRequest` | `tools` | 否 | `array` | gemini:generate_content standard | native | mapped | mapped | functionDeclarations map; built-in tools require explicit target mapping | -| Gemini | `GenerateContentResponse` | `candidates` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerateContentResponse` | `modelStatus` | 否 | `ModelStatus` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerateContentResponse` | `modelVersion` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerateContentResponse` | `promptFeedback` | 否 | `PromptFeedback` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerateContentResponse` | `responseId` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerateContentResponse` | `usageMetadata` | 否 | `UsageMetadata` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `_responseJsonSchema` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `candidateCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `enableEnhancedCivicAnswers` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `frequencyPenalty` | 否 | `number(float)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `imageConfig` | 否 | `ImageConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `logprobs` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `maxOutputTokens` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `mediaResolution` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `presencePenalty` | 否 | `number(float)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `responseFormat` | 否 | `ResponseFormatConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `responseJsonSchema` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `responseLogprobs` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `responseMimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `responseModalities` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `responseSchema` | 否 | `Schema` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `seed` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `speechConfig` | 否 | `SpeechConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `stopSequences` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `temperature` | 否 | `number(float)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `thinkingConfig` | 否 | `ThinkingConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `topK` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GenerationConfig` | `topP` | 否 | `number(float)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GoogleAiGenerativelanguageV1betaGroundingSupport` | `confidenceScores` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaGroundingSupport` | `groundingChunkIndices` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaGroundingSupport` | `renderedParts` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaGroundingSupport` | `segment` | 否 | `GoogleAiGenerativelanguageV1betaSegment` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaSegment` | `endIndex` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaSegment` | `partIndex` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaSegment` | `startIndex` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleAiGenerativelanguageV1betaSegment` | `text` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GoogleMaps` | `enableWidget` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GoogleSearch` | `searchTypes` | 否 | `SearchTypes` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GoogleSearch` | `timeRangeFilter` | 否 | `Interval` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GoogleSearchRetrieval` | `dynamicRetrievalConfig` | 否 | `DynamicRetrievalConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingAttribution` | `content` | 否 | `Content` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingAttribution` | `sourceId` | 否 | `AttributionSourceId` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingChunk` | `image` | 否 | `Image` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingChunk` | `maps` | 否 | `Maps` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingChunk` | `retrievedContext` | 否 | `RetrievedContext` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingChunk` | `web` | 否 | `Web` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingChunkCustomMetadata` | `key` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingChunkCustomMetadata` | `numericValue` | 否 | `number(float)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingChunkCustomMetadata` | `stringListValue` | 否 | `GroundingChunkStringList` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingChunkCustomMetadata` | `stringValue` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingChunkStringList` | `values` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingMetadata` | `googleMapsWidgetContextToken` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingMetadata` | `groundingChunks` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingMetadata` | `groundingSupports` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingMetadata` | `imageSearchQueries` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingMetadata` | `retrievalMetadata` | 否 | `RetrievalMetadata` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingMetadata` | `searchEntryPoint` | 否 | `SearchEntryPoint` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingMetadata` | `webSearchQueries` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `GroundingPassageId` | `partIndex` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `GroundingPassageId` | `passageId` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Image` | `domain` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Image` | `imageUri` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Image` | `sourceUri` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Image` | `title` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ImageConfig` | `aspectRatio` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ImageConfig` | `imageSize` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ImageResponseFormat` | `aspectRatio` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ImageResponseFormat` | `delivery` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ImageResponseFormat` | `imageSize` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ImageResponseFormat` | `mimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Interval` | `endTime` | 否 | `string(google-datetime)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Interval` | `startTime` | 否 | `string(google-datetime)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `LatLng` | `latitude` | 否 | `number(double)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `LatLng` | `longitude` | 否 | `number(double)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ListFilesResponse` | `files` | 否 | `array` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ListFilesResponse` | `nextPageToken` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `LogprobsResult` | `chosenCandidates` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `LogprobsResult` | `logProbabilitySum` | 否 | `number(float)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `LogprobsResult` | `topCandidates` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `LogprobsResultCandidate` | `logProbability` | 否 | `number(float)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `LogprobsResultCandidate` | `token` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `LogprobsResultCandidate` | `tokenId` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Maps` | `placeAnswerSources` | 否 | `PlaceAnswerSources` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Maps` | `placeId` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Maps` | `text` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Maps` | `title` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Maps` | `uri` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `McpServer` | `name` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `McpServer` | `streamableHttpTransport` | 否 | `StreamableHttpTransport` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ModalityTokenCount` | `modality` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ModalityTokenCount` | `tokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ModelStatus` | `message` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ModelStatus` | `modelStage` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ModelStatus` | `retirementTime` | 否 | `string(google-datetime)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `MultiSpeakerVoiceConfig` | `speakerVoiceConfigs` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Operation` | `done` | 否 | `boolean` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Operation` | `error` | 否 | `Status` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Operation` | `metadata` | 否 | `object/map` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Operation` | `name` | 否 | `string` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Operation` | `response` | 否 | `object/map` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Part` | `codeExecutionResult` | 否 | `CodeExecutionResult` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `executableCode` | 否 | `ExecutableCode` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `fileData` | 否 | `FileData` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `functionCall` | 否 | `FunctionCall` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `functionResponse` | 否 | `FunctionResponse` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `inlineData` | 否 | `Blob` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `mediaResolution` | 否 | `MediaResolution` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `partMetadata` | 否 | `object/map` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `text` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `thought` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `thoughtSignature` | 否 | `string(byte)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `toolCall` | 否 | `ToolCall` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `toolResponse` | 否 | `ToolResponse` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Part` | `videoMetadata` | 否 | `VideoMetadata` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `PlaceAnswerSources` | `reviewSnippets` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `PrebuiltVoiceConfig` | `voiceName` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `PredictLongRunningRequest` | `instances` | 否 | `array` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `PredictLongRunningRequest` | `parameters` | 否 | `any` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `PromptFeedback` | `blockReason` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `PromptFeedback` | `safetyRatings` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ResponseFormatConfig` | `audio` | 否 | `AudioResponseFormat` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ResponseFormatConfig` | `image` | 否 | `ImageResponseFormat` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ResponseFormatConfig` | `text` | 否 | `TextResponseFormat` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `RetrievalConfig` | `languageCode` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievalConfig` | `latLng` | 否 | `LatLng` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievalMetadata` | `googleSearchDynamicRetrievalScore` | 否 | `number(float)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `customMetadata` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `fileSearchStore` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `mediaId` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `pageNumber` | 否 | `integer(int32)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `text` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `title` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `RetrievedContext` | `uri` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ReviewSnippet` | `googleMapsUri` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ReviewSnippet` | `reviewId` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ReviewSnippet` | `title` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SafetyRating` | `blocked` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `SafetyRating` | `category` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `SafetyRating` | `probability` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `SafetySetting` | `category` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `SafetySetting` | `threshold` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `anyOf` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `default` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `description` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `enum` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `example` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `format` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `items` | 否 | `Schema` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `maxItems` | 否 | `string(int64)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `maxLength` | 否 | `string(int64)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `maxProperties` | 否 | `string(int64)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `maximum` | 否 | `number(double)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `minItems` | 否 | `string(int64)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `minLength` | 否 | `string(int64)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `minProperties` | 否 | `string(int64)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `minimum` | 否 | `number(double)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `nullable` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `pattern` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `properties` | 否 | `object/map` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `propertyOrdering` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `required` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `title` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Schema` | `type` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `SearchEntryPoint` | `renderedContent` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SearchEntryPoint` | `sdkBlob` | 否 | `string(byte)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SearchTypes` | `imageSearch` | 否 | `ImageSearch` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SearchTypes` | `webSearch` | 否 | `WebSearch` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SemanticRetrieverChunk` | `chunk` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SemanticRetrieverChunk` | `source` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SpeakerVoiceConfig` | `speaker` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SpeakerVoiceConfig` | `voiceConfig` | 否 | `VoiceConfig` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SpeechConfig` | `languageCode` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SpeechConfig` | `multiSpeakerVoiceConfig` | 否 | `MultiSpeakerVoiceConfig` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `SpeechConfig` | `voiceConfig` | 否 | `VoiceConfig` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Status` | `code` | 否 | `integer(int32)` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Status` | `details` | 否 | `array>` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Status` | `message` | 否 | `string` | gemini:files native-only | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `StreamableHttpTransport` | `headers` | 否 | `object/map` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `StreamableHttpTransport` | `sseReadTimeout` | 否 | `string(google-duration)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `StreamableHttpTransport` | `terminateOnClose` | 否 | `boolean` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `StreamableHttpTransport` | `timeout` | 否 | `string(google-duration)` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `StreamableHttpTransport` | `url` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `TextResponseFormat` | `mimeType` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `TextResponseFormat` | `schema` | 否 | `any` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ThinkingConfig` | `includeThoughts` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ThinkingConfig` | `thinkingBudget` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ThinkingConfig` | `thinkingLevel` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `codeExecution` | 否 | `CodeExecution` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `computerUse` | 否 | `ComputerUse` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `fileSearch` | 否 | `FileSearch` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `functionDeclarations` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `googleMaps` | 否 | `GoogleMaps` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `googleSearch` | 否 | `GoogleSearch` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `googleSearchRetrieval` | 否 | `GoogleSearchRetrieval` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `mcpServers` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `Tool` | `urlContext` | 否 | `UrlContext` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ToolCall` | `args` | 否 | `object/map` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ToolCall` | `id` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ToolCall` | `toolType` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ToolConfig` | `functionCallingConfig` | 否 | `FunctionCallingConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ToolConfig` | `includeServerSideToolInvocations` | 否 | `boolean` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ToolConfig` | `retrievalConfig` | 否 | `RetrievalConfig` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `ToolResponse` | `id` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ToolResponse` | `response` | 否 | `object/map` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `ToolResponse` | `toolType` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `TopCandidates` | `candidates` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UrlContextMetadata` | `urlMetadata` | 否 | `array` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `UrlMetadata` | `retrievedUrl` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `UrlMetadata` | `urlRetrievalStatus` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `UsageMetadata` | `cacheTokensDetails` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `cachedContentTokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `candidatesTokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `candidatesTokensDetails` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `promptTokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `promptTokensDetails` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `serviceTier` | 否 | `string` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini provider-only field has no lossless OpenAI/Claude target equivalent | -| Gemini | `UsageMetadata` | `thoughtsTokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `toolUsePromptTokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `toolUsePromptTokensDetails` | 否 | `array` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `UsageMetadata` | `totalTokenCount` | 否 | `integer(int32)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `VideoFileMetadata` | `videoDuration` | 否 | `string(google-duration)` | gemini:video/native operation | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `VideoMetadata` | `endOffset` | 否 | `string(google-duration)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `VideoMetadata` | `fps` | 否 | `number(double)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `VideoMetadata` | `startOffset` | 否 | `string(google-duration)` | gemini:generate_content standard | native | extension-preserved | lossy-blocked | Gemini nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed | -| Gemini | `VoiceConfig` | `prebuiltVoiceConfig` | 否 | `PrebuiltVoiceConfig` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Web` | `title` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | -| Gemini | `Web` | `uri` | 否 | `string` | gemini auxiliary / not-in-conversion-surface | native | not-in-conversion-surface | not-in-conversion-surface | not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly | - -Total covered schema fields: 2079. diff --git a/docs/api/format-passthrough-contract.md b/docs/api/format-passthrough-contract.md deleted file mode 100644 index 593ed561b..000000000 --- a/docs/api/format-passthrough-contract.md +++ /dev/null @@ -1,85 +0,0 @@ -# Format Passthrough Contract - -Last audited: 2026-06-03 - -This document defines the boundary between runtime passthrough, canonical roundtrip tests, and cross-format conversion. - -## Runtime Same-Format Path - -Runtime same-format provider paths must not call canonical conversion. - -Current implementation: - -- `crates/aether-provider/transport/src/same_format_provider/mod.rs` checks `api_format_alias_matches(client_api_format, provider_api_format)`. -- When formats match, the provider body is built by copying the parsed JSON object field-for-field. -- When formats differ, the provider body is built through `aether_ai_formats::convert_request_pure`. -- Model override, body rules, model directives, Claude Code sanitization, Gemini function-response id stripping, and stream policy are applied only after the passthrough/conversion branch in provider transport. - -Important limitation: - -- The current transport helper receives `body_json: &serde_json::Value`, not raw request bytes. It therefore guarantees no canonical conversion and JSON value preservation at this layer, but it cannot preserve original whitespace or object key order by itself. -- True byte-level passthrough for requests with no transport edits requires a higher-level raw-body path that can forward the original bytes directly. Until that raw-body plumbing exists, tests should assert "conversion module not called" and JSON value equivalence for this helper, not byte-for-byte serialization equivalence. - -Provider schema drift does not change this rule. If OpenAI, Claude, or Gemini add a new field, same-format runtime routing must still forward it as part of the original provider body. The schema inventory and field coverage matrix are audit aids, not the runtime allowlist for same-format traffic. - -## Canonical Same-Format Roundtrip - -Canonical same-format roundtrip is only a test/audit mode: - -```text -source format -> Canonical -> same source format -``` - -Required behavior: - -- JSON-normalized equality, ignoring object field order and whitespace. -- Field values, array order, unknown fields, extension namespaces, and unknown enum strings must be preserved. -- This path may parse and emit; it is not the runtime path. -- Unknown provider fields are carried in provider extension namespaces and replayed when emitting the same provider format. - -## Cross-Format Conversion - -Cross-format conversion is strict: - -```text -source format -> Canonical -> target format -``` - -Required behavior: - -- Emit only fields valid for the target provider format. -- Map provider-specific enum values through explicit provider enum types. -- Preserve source fields only when the target has an equivalent field or documented extension passthrough. -- Fail closed with `FormatError::UnauditedField`, `FormatError::LossyConversionBlocked`, `FormatError::UnsupportedField`, `FormatError::InvalidEnumValue`, or `FormatError::InvalidTargetField` when no lossless mapping exists. -- Do not use `None` or silent omission to represent conversion failure. -- Newly added provider fields follow the same rule as other unknown fields: preserve same-format, fail closed cross-format with `UnauditedField`. A code change is required only when Aether intentionally supports a new cross-format semantic mapping. - -## Pure Conversion Interface - -Pure conversion lives in `crates/aether-ai/formats` and is limited to: - -- parse -- emit -- provider-specific field/enum mapping -- `ConversionReport` - -Pure conversion must not: - -- override `model` -- add, remove, or force `stream` -- apply body rules -- apply model directives -- read the original request body to patch missing target fields -- perform provider transport policy edits - -Current pure entrypoints: - -- `parse_request_pure` -- `emit_request_pure` -- `convert_request_pure` -- `convert_request_pure_with_context` -- `parse_response_pure` -- `emit_response_pure` -- `convert_response_pure` - -`convert_request` and `convert_response` remain legacy wrappers for existing callers that still need mapped model/report-context behavior during migration. diff --git a/docs/api/generate_format_field_coverage.py b/docs/api/generate_format_field_coverage.py deleted file mode 100644 index 644c1ff40..000000000 --- a/docs/api/generate_format_field_coverage.py +++ /dev/null @@ -1,548 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the provider schema field coverage matrix. - -The input inventory is docs/api/provider-interface-definitions.md. Existing -coverage rows are reused so audited status/notes survive regeneration. Newly -introduced provider fields get conservative same-format/native and cross-format -fail-closed defaults until a human audits whether they deserve an explicit -mapping. -""" - -from __future__ import annotations - -import argparse -import dataclasses -from collections import Counter, defaultdict -from pathlib import Path -from typing import Iterable - - -ROOT = Path(__file__).resolve().parents[2] -DEFAULT_DEFINITIONS = ROOT / "docs/api/provider-interface-definitions.md" -DEFAULT_MATRIX = ROOT / "docs/api/format-field-coverage-matrix.md" - - -@dataclasses.dataclass(frozen=True) -class SourceField: - provider: str - schema: str - field: str - required: str - field_type: str - - -@dataclasses.dataclass(frozen=True) -class CoverageStatus: - surface: str - same_format_runtime: str - canonical_roundtrip: str - cross_format: str - notes: str - - -OPENAI_CHAT_MAPPED = { - "model", - "messages", - "max_tokens", - "max_completion_tokens", - "temperature", - "top_p", - "top_logprobs", - "tools", - "tool_choice", - "parallel_tool_calls", - "metadata", - "response_format", - "reasoning_effort", - "verbosity", - "store", - "service_tier", - "safety_identifier", - "prompt_cache_key", - "prompt_cache_retention", - "stream", -} - -OPENAI_CHAT_BLOCKED = { - "n", - "stop", - "presence_penalty", - "frequency_penalty", - "seed", - "logprobs", - "stream_options", - "user", - "function_call", - "functions", - "logit_bias", - "modalities", - "prediction", - "audio", - "web_search_options", -} - -OPENAI_RESPONSES_MAPPED = { - "model", - "input", - "instructions", - "max_output_tokens", - "temperature", - "top_p", - "top_logprobs", - "metadata", - "parallel_tool_calls", - "text", - "tools", - "tool_choice", - "reasoning", - "store", - "service_tier", - "safety_identifier", - "prompt_cache_key", - "prompt_cache_retention", -} - -OPENAI_RESPONSES_BLOCKED = { - "include", - "previous_response_id", - "truncation", - "prompt", - "conversation", - "background", - "max_tool_calls", - "user", - "context_management", - "stream", - "stream_options", -} - -CLAUDE_MAPPED_FIELDS = { - "id", - "type", - "role", - "text", - "content", - "source", - "name", - "description", - "input", - "input_schema", - "messages", - "model", - "max_tokens", - "system", - "temperature", - "top_p", - "top_k", - "stop_sequences", - "tool_choice", - "tools", - "metadata", - "thinking", - "output_config", - "usage", - "stop_reason", - "stop_sequence", -} - -CLAUDE_PROVIDER_ONLY_FIELDS = { - "cache_control", - "container", - "inference_geo", - "service_tier", - "allowed_callers", - "allowed_domains", - "blocked_domains", - "defer_loading", - "max_uses", - "strict", - "user_location", - "citations", - "context", - "title", - "file_id", - "document_index", - "document_title", - "cited_text", - "caller", -} - - -def split_markdown_row(line: str) -> list[str]: - cells: list[str] = [] - current: list[str] = [] - escaped = False - for char in line: - if char == "|" and not escaped: - cells.append("".join(current).strip()) - current.clear() - else: - current.append(char) - escaped = char == "\\" and not escaped - if escaped and char != "\\": - escaped = False - cells.append("".join(current).strip()) - return cells - - -def strip_markdown_code(value: str) -> str: - value = value.strip() - if value.startswith("`") and value.endswith("`"): - value = value[1:-1] - return value.replace("\\|", "|") - - -def escape_markdown_cell(value: str) -> str: - return value.replace("|", "\\|") - - -def parse_schema_heading(line: str) -> str | None: - if not line.startswith("### `"): - return None - rest = line[len("### `") :] - schema, _, _ = rest.partition("`") - return schema or None - - -def parse_provider_definition_fields(definitions: str) -> list[SourceField]: - provider: str | None = None - schema: str | None = None - fields: list[SourceField] = [] - - for line in definitions.splitlines(): - if line.startswith("## "): - if "OpenAI Schema" in line: - provider = "OpenAI" - elif "Claude / Anthropic TypeScript" in line: - provider = "Claude" - elif "Gemini Schema" in line: - provider = "Gemini" - else: - provider = None - schema = None - continue - - if provider is None: - continue - - if heading := parse_schema_heading(line): - schema = heading - continue - - if schema is None or not line.startswith("| `"): - continue - - cells = split_markdown_row(line) - if len(cells) < 4 or cells[2] not in {"是", "否"}: - continue - fields.append( - SourceField( - provider=provider, - schema=schema, - field=strip_markdown_code(cells[1]), - required=cells[2], - field_type=strip_markdown_code(cells[3]), - ) - ) - - return fields - - -def parse_existing_coverage( - matrix: str, -) -> tuple[dict[tuple[str, str, str], CoverageStatus], dict[tuple[str, str], list[CoverageStatus]]]: - existing: dict[tuple[str, str, str], CoverageStatus] = {} - profiles: dict[tuple[str, str], list[CoverageStatus]] = defaultdict(list) - - for line in matrix.splitlines(): - if not line.startswith("| "): - continue - cells = split_markdown_row(line) - if len(cells) < 11 or cells[1] not in {"OpenAI", "Claude", "Gemini"}: - continue - status = CoverageStatus( - surface=cells[6], - same_format_runtime=cells[7], - canonical_roundtrip=cells[8], - cross_format=cells[9], - notes=cells[10], - ) - provider = cells[1] - schema = strip_markdown_code(cells[2]) - field = strip_markdown_code(cells[3]) - existing[(provider, schema, field)] = status - profiles[(provider, schema)].append(status) - - return existing, profiles - - -def most_common(values: Iterable[str]) -> str | None: - values = list(values) - if not values: - return None - return Counter(values).most_common(1)[0][0] - - -def openai_surface(schema: str) -> str: - if "CreateChatCompletion" in schema or "ChatCompletion" in schema: - return "openai:chat standard" - if "CreateEmbedding" in schema or "Embedding" in schema: - return "openai:embedding" - if any(token in schema for token in ("CreateImage", "EditImage", "Image", "Images")): - return "openai:image native-only" - if any(token in schema for token in ("Compact", "Compaction")): - return "openai:responses:compact native-only" - if any( - token in schema - for token in ( - "Response", - "Input", - "Output", - "Tool", - "Reasoning", - "WebSearch", - "FileSearch", - "Computer", - "MCP", - "CodeInterpreter", - "Function", - "Custom", - "EasyInput", - "Prompt", - "Conversation", - "Annotation", - "Citation", - "LogProb", - "TopLogProb", - "Metadata", - "ServiceTier", - "Verbosity", - "TextResponse", - "ResponseFormat", - "Include", - "Modalities", - "ParallelToolCalls", - "StopConfiguration", - ) - ): - return "openai:responses standard" - return "openai auxiliary / not-in-conversion-surface" - - -def openai_default_status(field: SourceField, profile: list[CoverageStatus]) -> CoverageStatus: - surface = most_common(status.surface for status in profile) or openai_surface(field.schema) - if "not-in-conversion-surface" in surface or "native-only" in surface: - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip="not-in-conversion-surface", - cross_format="not-in-conversion-surface", - notes="not part of current canonical cross-format conversion; same-format runtime path remains provider-native when routed directly", - ) - if field.schema == "CreateChatCompletionRequest": - if field.field in OPENAI_CHAT_MAPPED: - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip="mapped", - cross_format="mapped", - notes="Chat request field maps provider-specifically; target-incompatible cases fail closed", - ) - if field.field in OPENAI_CHAT_BLOCKED: - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip="extension-preserved", - cross_format="lossy-blocked", - notes="Chat-only or provider-specific field has no audited lossless target equivalent", - ) - if field.schema == "CreateResponse": - if field.field in OPENAI_RESPONSES_MAPPED: - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip="mapped", - cross_format="mapped", - notes="Responses request field maps provider-specifically; target-incompatible cases fail closed", - ) - if field.field in OPENAI_RESPONSES_BLOCKED: - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip="extension-preserved", - cross_format="lossy-blocked", - notes="Responses-only field has no audited lossless Chat/Claude/Gemini target equivalent", - ) - if profile: - cross_format = most_common(status.cross_format for status in profile) or "lossy-blocked" - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip=most_common(status.canonical_roundtrip for status in profile) - or "extension-preserved", - cross_format=cross_format, - notes=next( - (status.notes for status in profile if status.cross_format == cross_format), - "schema-level handling inherited from audited sibling fields", - ), - ) - return CoverageStatus( - surface=surface, - same_format_runtime="native", - canonical_roundtrip="extension-preserved", - cross_format="lossy-blocked", - notes="OpenAI documented field is preserved same-format; cross-format requires explicit target mapping or fails closed", - ) - - -def claude_default_status(field: SourceField) -> CoverageStatus: - if "CountTokens" in field.schema: - return CoverageStatus( - surface="claude:messages/count_tokens native-only", - same_format_runtime="native", - canonical_roundtrip="not-in-conversion-surface", - cross_format="not-in-conversion-surface", - notes="count_tokens schemas are provider-native and outside canonical generation conversion", - ) - if field.field in CLAUDE_MAPPED_FIELDS: - return CoverageStatus( - surface="claude:messages standard", - same_format_runtime="native", - canonical_roundtrip="mapped", - cross_format="mapped/lossy-blocked", - notes="Claude field maps where canonical and target support an equivalent; otherwise conversion fails closed", - ) - if ( - field.field in CLAUDE_PROVIDER_ONLY_FIELDS - or field.field.endswith("_tokens_details") - or "cache" in field.field - ): - return CoverageStatus( - surface="claude:messages standard", - same_format_runtime="native", - canonical_roundtrip="extension-preserved", - cross_format="lossy-blocked", - notes="Claude provider-specific field is preserved same-format and blocked cross-format without an audited target equivalent", - ) - return CoverageStatus( - surface="claude:messages standard", - same_format_runtime="native", - canonical_roundtrip="extension-preserved", - cross_format="lossy-blocked", - notes="Claude nested/provider-specific field is same-format preserved; cross-format requires explicit mapping or fails closed", - ) - - -def gemini_default_status(field: SourceField, profile: list[CoverageStatus]) -> CoverageStatus: - if profile: - cross_format = most_common(status.cross_format for status in profile) or "lossy-blocked" - return CoverageStatus( - surface=most_common(status.surface for status in profile) - or "gemini:generate_content standard", - same_format_runtime="native", - canonical_roundtrip=most_common(status.canonical_roundtrip for status in profile) - or "extension-preserved", - cross_format=cross_format, - notes=next( - (status.notes for status in profile if status.cross_format == cross_format), - "Gemini field follows schema-level handling", - ), - ) - return CoverageStatus( - surface="gemini:generate_content standard", - same_format_runtime="native", - canonical_roundtrip="extension-preserved", - cross_format="lossy-blocked", - notes="Gemini documented field is preserved same-format; cross-format requires explicit mapping or fails closed", - ) - - -def default_status(field: SourceField, profile: list[CoverageStatus]) -> CoverageStatus: - if field.provider == "OpenAI": - return openai_default_status(field, profile) - if field.provider == "Claude": - return claude_default_status(field) - if field.provider == "Gemini": - return gemini_default_status(field, profile) - raise ValueError(f"unsupported provider: {field.provider}") - - -def render_matrix( - fields: list[SourceField], - existing: dict[tuple[str, str, str], CoverageStatus], - profiles: dict[tuple[str, str], list[CoverageStatus]], -) -> str: - rows: list[str] = [ - "# Format Field Coverage Matrix", - "", - "Last generated: 2026-06-03", - "", - "This file is generated from the schema inventory in `docs/api/provider-interface-definitions.md` and gives every documented schema field an explicit handling status. “处理到” here means the field is either mapped, preserved in same-format paths, rejected with a structured fail-closed error, or explicitly outside the current conversion surface. It does not mean every field can be cross-format converted.", - "", - "Provider schema updates do not require immediate conversion-code changes for runtime safety. Same-format runtime paths bypass canonical conversion, and same-format canonical roundtrip preserves provider extension fields. Cross-format conversion only enables fields with an audited semantic mapping; newly discovered or unknown provider fields default to structured fail-closed behavior until mapped.", - "", - "Regenerate with: `python3 docs/api/generate_format_field_coverage.py`.", - "", - "Statuses used in this matrix: `native`, `mapped`, `mapped/lossy-blocked`, `extension-preserved`, `unaudited`, `unsupported`, `invalid-enum`, `lossy-blocked`, `not-in-conversion-surface`.", - "", - "| Provider | Schema | Field | Required | Type | Surface | Same-Format Runtime | Canonical Roundtrip | Cross-Format | Notes |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", - ] - - for field in fields: - status = existing.get( - (field.provider, field.schema, field.field), - default_status(field, profiles[(field.provider, field.schema)]), - ) - rows.append( - "| " - + " | ".join( - [ - field.provider, - f"`{escape_markdown_cell(field.schema)}`", - f"`{escape_markdown_cell(field.field)}`", - field.required, - f"`{escape_markdown_cell(field.field_type)}`", - escape_markdown_cell(status.surface), - escape_markdown_cell(status.same_format_runtime), - escape_markdown_cell(status.canonical_roundtrip), - escape_markdown_cell(status.cross_format), - escape_markdown_cell(status.notes), - ] - ) - + " |" - ) - - rows.extend(["", f"Total covered schema fields: {len(fields)}."]) - return "\n".join(rows) + "\n" - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--definitions", type=Path, default=DEFAULT_DEFINITIONS) - parser.add_argument("--matrix", type=Path, default=DEFAULT_MATRIX) - parser.add_argument("--check", action="store_true") - args = parser.parse_args() - - definitions = args.definitions.read_text() - current_matrix = args.matrix.read_text() if args.matrix.exists() else "" - fields = parse_provider_definition_fields(definitions) - existing, profiles = parse_existing_coverage(current_matrix) - next_matrix = render_matrix(fields, existing, profiles) - - if args.check: - if current_matrix != next_matrix: - print( - f"{args.matrix} is not up to date; run " - "`python3 docs/api/generate_format_field_coverage.py`", - ) - return 1 - return 0 - - args.matrix.write_text(next_matrix) - print(f"wrote {len(fields)} field coverage rows to {args.matrix}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docs/api/provider-health-summary.md b/docs/api/provider-health-summary.md deleted file mode 100644 index 30098c096..000000000 --- a/docs/api/provider-health-summary.md +++ /dev/null @@ -1,53 +0,0 @@ -# 提供商管理的端点健康度 - -以下管理接口使用相同的健康度汇总规则: - -- `GET /api/admin/providers/summary` -- `GET /api/admin/providers/{provider_id}/summary` - -## 统计规则 - -沿用 `v0.7.13`(`535ee098c`)的默认健康规则:已启用密钥缺少该格式的健康记录时, -按 `1.0` 参与统计,而不是要求先有一次请求或探测才能显示健康。 - -- 仅统计启用端点下、支持该端点 API 格式的启用密钥。 -- 每个密钥读取其 `health_by_format[api_format].health_score`,不跨格式借用分数。 -- 对上述启用密钥求算术平均;缺少有效分数的密钥沿用旧版默认值 `1.0`。 -- 分数范围为 `0` 到 `1`,沿用调度器的分数读取与范围约束。 -- 停用端点或没有启用密钥时,端点的 `health_score` 返回 `null`。 -- `avg_health_score` 仅平均有启用密钥的启用端点,包含按默认值计算的端点;没有此类端点时返回 `null`。 -- `unhealthy_endpoints` 仅统计上述端点中健康度低于 `0.5` 的数量,不把未知状态算作故障。 - -`total_keys` 和 `active_keys` 仍反映密钥配置数量,不因缺少健康数据而减少。 - -Codex、Kiro、Gemini CLI、Antigravity 等固定提供商的账号按照各提供商的认证规则, -继承其启用端点的 API 格式;账号的 `api_formats` 为 `null` 或空数组,不代表没有配置账号。 -继承格式决定账号归属;缺少对应格式的分数时同样使用默认值 `1.0`,不借用其他格式的异常分数。 - -## 数据读取 - -摘要从密钥的轻量投影读取 API 格式、启用状态和 `health_by_format`。 -PostgreSQL 投影中的凭据字段使用 `summary` / `{}` 等脱敏占位值,并非真实密文, -因此摘要读取不执行凭据解密、认证或迁移。完整密钥读取仍保留原有的凭据安全校验。 - -若将这些占位值送入凭据校验,读取会失败,旧的摘要聚合还会将其当作空密钥列表, -导致已配置密钥的端点也被错误显示为灰色。默认健康分数只能用于成功读取的启用密钥, -不能用于掩盖查询或凭据投影错误。 - -提供商、端点或密钥摘要查询失败时,接口返回 `503`,不能将失败当作空列表并返回零账号。 -单个提供商确实不存在时仍返回 `404`。页面刷新失败保留已有列表,并显示加载错误。 - -## 页面展示 - -桌面表格、网格卡片和手机卡片使用相同规则: - -- 有健康数据时显示百分比;有效的零分显示 `0%`。 -- 有启用密钥、但这些密钥尚无该格式的健康记录时,显示绿色 `100%`,与 `v0.7.13` 一致。 -- 端点停用、未配置密钥或没有启用密钥时显示灰色占位条和对应状态提示。 - -账号详情保留原有默认 `100%` 的规则。已有观测仍取各格式最低分;端点分数则只聚合对应格式, -两者统计范围不同,不要求百分比完全相等。调度器原有的缺省健康策略不变。 - -此分数是密钥当前健康状态的聚合,包含默认健康值,不是某个时间窗口内的请求成功率, -也不表示已经执行过主动探测。相比 `v0.7.13`,仍保留停用账号/端点不参与健康聚合、 -凭据脱敏与查询失败显式报错等修复,不整体回退旧版代码。 diff --git a/docs/api/provider-interface-definitions.md b/docs/api/provider-interface-definitions.md deleted file mode 100644 index 353a1b822..000000000 --- a/docs/api/provider-interface-definitions.md +++ /dev/null @@ -1,7665 +0,0 @@ -# OpenAI / Claude / Gemini 接口定义 - -生成日期:2026-06-03。 - -本文档整理 Aether 当前接入和转换矩阵实际涉及的三类 provider 接口面:OpenAI Chat Completions / Responses / Embeddings / Images,Claude Messages,以及 Gemini GenerateContent / Embeddings / Files / PredictLongRunning 相关接口。它不是三家公司所有管理类、训练类、账单类 API 的全集。 - -这是 schema inventory / audit input,不是运行时代码的字段 allowlist。Provider 官方新增字段时,同格式运行时路径仍按原始 body 透传;canonical same-format roundtrip 通过 provider extension 保留未映射字段;跨格式转换只有在存在显式语义映射时才开放,否则 fail closed。刷新本文档只用于更新审计基线和决定是否新增跨格式映射。 - -## 来源与范围 - -| Provider | 结构化来源 | 官方参考 | 本文档覆盖 | -| --- | --- | --- | --- | -| OpenAI | OpenAI OpenAPI `2.3.0` / `OpenAI API` | https://platform.openai.com/docs/api-reference | `/v1/chat/completions`, `/v1/responses`, `/v1/responses/compact`, `/v1/embeddings`, `/v1/images/*` | -| Claude / Anthropic | `anthropic-sdk-typescript` 中由 Anthropic OpenAPI 生成的 `messages.ts` | https://docs.anthropic.com/en/api/messages | `/v1/messages`, `/v1/messages/count_tokens`, Messages streaming events | -| Gemini | Google Generative Language Discovery `v1beta` / `Gemini API` | https://ai.google.dev/api | `generateContent`, `streamGenerateContent`, `embedContent`, `batchEmbedContents`, files, count tokens, predict long-running | - -结构化来源 URL: - -- OpenAI OpenAPI: https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml -- Anthropic Messages SDK types: https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/messages/messages.ts -- Gemini Discovery JSON: https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta - -说明:字段表中的“必填”来自官方 schema 的 `required` 或 TypeScript `?` 标记;很多接口还会受到模型、账号权限、beta header、区域、Aether provider 配置和上游版本的约束。Aether 的 `/v1/rerank` 是 OpenAI/Jina compatible 兼容面,不是 OpenAI 官方 OpenAPI 中的 endpoint;它见 `docs/api/rerank.md`。 - -## Aether API Format 对应关系 - -| Aether format | Provider 原生接口 | 请求根 schema | 响应根 schema | -| --- | --- | --- | --- | -| `openai:chat` | `POST /v1/chat/completions` | `CreateChatCompletionRequest` | `CreateChatCompletionResponse` 或 `CreateChatCompletionStreamResponse` | -| `openai:responses` | `POST /v1/responses` | `CreateResponse` | `Response` 或 `ResponseStreamEvent` | -| `openai:responses:compact` | `POST /v1/responses/compact` | `CompactResponseMethodPublicBody` | `CompactResource` | -| `openai:embedding` | `POST /v1/embeddings` | `CreateEmbeddingRequest` | `CreateEmbeddingResponse` | -| `openai:image` | `POST /v1/images/generations`, `/edits`, `/variations` | `CreateImageRequest`, `CreateImageEditRequest`, `CreateImageVariationRequest` | `ImagesResponse` 或 image stream event | -| `claude:messages` | `POST /v1/messages` | `MessageCreateParams` | `Message` 或 `RawMessageStreamEvent` | -| `gemini:generate_content` | `models/{model}:generateContent` / `:streamGenerateContent` | `GenerateContentRequest` | `GenerateContentResponse` | -| `gemini:embedding` | `models/{model}:embedContent` / `:batchEmbedContents` | `EmbedContentRequest` / `BatchEmbedContentsRequest` | `EmbedContentResponse` / `BatchEmbedContentsResponse` | - -## OpenAI Endpoints - -| Method | Path | Request content type | Request schema | Response schema | -| --- | --- | --- | --- | --- | -| POST | `/chat/completions` | `application/json` | `CreateChatCompletionRequest` | `CreateChatCompletionResponse` / `CreateChatCompletionStreamResponse` | -| POST | `/responses` | `application/json` | `CreateResponse` | `Response` / `ResponseStreamEvent` | -| POST | `/responses/compact` | `application/json, application/x-www-form-urlencoded` | `CompactResponseMethodPublicBody` | `CompactResource` | -| POST | `/embeddings` | `application/json` | `CreateEmbeddingRequest` | `CreateEmbeddingResponse` | -| POST | `/images/generations` | `application/json` | `CreateImageRequest` | `ImagesResponse` / `ImageGenStreamEvent` | -| POST | `/images/edits` | `multipart/form-data, application/json` | `CreateImageEditRequest` / `EditImageBodyJsonParam` | `ImagesResponse` / `ImageEditStreamEvent` | -| POST | `/images/variations` | `multipart/form-data` | `CreateImageVariationRequest` | `ImagesResponse` | - -## OpenAI Schema 字段表 - -以下 schema 从上述 OpenAI endpoint 根 schema 递归引用得到,共 351 个。 - -### `AdditionalTools` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The unique ID of the additional tools item. | -| `role` | 是 | `MessageRole` | - | The role that provided the additional tools. | -| `tools` | 是 | `array` | - | The additional tool definitions made available at this item. | -| `type` | 是 | `string` | `additional_tools` | The type of the item. Always additional_tools. | - -### `AdditionalToolsItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 否 | `string \| null` | - | - | -| `role` | 是 | `string` | `developer` | The role that provided the additional tools. Only developer is supported. | -| `tools` | 是 | `array` | - | A list of additional tools made available at this item. | -| `type` | 是 | `string` | `additional_tools` | The item type. Always additional_tools. | - -### `Annotation` - -| 项 | 值 | -| --- | --- | -| 类型 | `FileCitationBody \| UrlCitationBody \| ContainerFileCitationBody \| FilePath` | -| 说明 | An annotation that applies to a span of output text. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `FileCitationBody` | - | -| 2 | `UrlCitationBody` | - | -| 3 | `ContainerFileCitationBody` | - | -| 4 | `FilePath` | - | - -### `ApplyPatchCallOutputStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ApplyPatchCallOutputStatusParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Outcome values reported for apply_patch tool call outputs. | - -### `ApplyPatchCallStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ApplyPatchCallStatusParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Status values reported for apply_patch tool calls. | - -### `ApplyPatchCreateFileOperation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Instruction describing how to create a file via the apply_patch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `diff` | 是 | `string` | - | Diff to apply. | -| `path` | 是 | `string` | - | Path of the file to create. | -| `type` | 是 | `string` | `create_file` | Create a new file with the provided diff. | - -### `ApplyPatchCreateFileOperationParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Instruction for creating a new file via the apply_patch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `diff` | 是 | `string` | - | Unified diff content to apply when creating the file. | -| `path` | 是 | `string` | - | Path of the file to create relative to the workspace root. | -| `type` | 是 | `string` | `create_file` | The operation type. Always create_file. | - -### `ApplyPatchDeleteFileOperation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Instruction describing how to delete a file via the apply_patch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `path` | 是 | `string` | - | Path of the file to delete. | -| `type` | 是 | `string` | `delete_file` | Delete the specified file. | - -### `ApplyPatchDeleteFileOperationParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Instruction for deleting an existing file via the apply_patch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `path` | 是 | `string` | - | Path of the file to delete relative to the workspace root. | -| `type` | 是 | `string` | `delete_file` | The operation type. Always delete_file. | - -### `ApplyPatchOperationParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `ApplyPatchCreateFileOperationParam \| ApplyPatchDeleteFileOperationParam \| ApplyPatchUpdateFileOperationParam` | -| 说明 | One of the create_file, delete_file, or update_file operations supplied to the apply_patch tool. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ApplyPatchCreateFileOperationParam` | - | -| 2 | `ApplyPatchDeleteFileOperationParam` | - | -| 3 | `ApplyPatchUpdateFileOperationParam` | - | - -### `ApplyPatchToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call that applies file diffs by creating, deleting, or updating files. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the apply patch tool call generated by the model. | -| `created_by` | 否 | `string` | - | The ID of the entity that created this tool call. | -| `id` | 是 | `string` | - | The unique ID of the apply patch tool call. Populated when this item is returned via API. | -| `operation` | 是 | `ApplyPatchCreateFileOperation \| ApplyPatchDeleteFileOperation \| ApplyPatchUpdateFileOperation` | - | One of the create_file, delete_file, or update_file operations applied via apply_patch. | -| `status` | 是 | `ApplyPatchCallStatus` | - | The status of the apply patch tool call. One of in_progress or completed. | -| `type` | 是 | `string` | `apply_patch_call` | The type of the item. Always apply_patch_call. | - -### `ApplyPatchToolCallItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call representing a request to create, delete, or update files using diff patches. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the apply patch tool call generated by the model. | -| `id` | 否 | `string \| null` | - | - | -| `operation` | 是 | `ApplyPatchOperationParam` | - | The specific create, delete, or update instruction for the apply_patch tool call. | -| `status` | 是 | `ApplyPatchCallStatusParam` | - | The status of the apply patch tool call. One of in_progress or completed. | -| `type` | 是 | `string` | `apply_patch_call` | The type of the item. Always apply_patch_call. | - -### `ApplyPatchToolCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output emitted by an apply patch tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the apply patch tool call generated by the model. | -| `created_by` | 否 | `string` | - | The ID of the entity that created this tool call output. | -| `id` | 是 | `string` | - | The unique ID of the apply patch tool call output. Populated when this item is returned via API. | -| `output` | 否 | `string \| null` | - | - | -| `status` | 是 | `ApplyPatchCallOutputStatus` | - | The status of the apply patch tool call output. One of completed or failed. | -| `type` | 是 | `string` | `apply_patch_call_output` | The type of the item. Always apply_patch_call_output. | - -### `ApplyPatchToolCallOutputItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The streamed output emitted by an apply patch tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the apply patch tool call generated by the model. | -| `id` | 否 | `string \| null` | - | - | -| `output` | 否 | `string \| null` | - | - | -| `status` | 是 | `ApplyPatchCallOutputStatusParam` | - | The status of the apply patch tool call output. One of completed or failed. | -| `type` | 是 | `string` | `apply_patch_call_output` | The type of the item. Always apply_patch_call_output. | - -### `ApplyPatchToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Allows the assistant to create, delete, or update files using unified diffs. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `apply_patch` | The type of the tool. Always apply_patch. | - -### `ApplyPatchUpdateFileOperation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Instruction describing how to update a file via the apply_patch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `diff` | 是 | `string` | - | Diff to apply. | -| `path` | 是 | `string` | - | Path of the file to update. | -| `type` | 是 | `string` | `update_file` | Update an existing file with the provided diff. | - -### `ApplyPatchUpdateFileOperationParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Instruction for updating an existing file via the apply_patch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `diff` | 是 | `string` | - | Unified diff content to apply to the existing file. | -| `path` | 是 | `string` | - | Path of the file to update relative to the workspace root. | -| `type` | 是 | `string` | `update_file` | The operation type. Always update_file. | - -### `ApproximateLocation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `city` | 否 | `string \| null` | - | - | -| `country` | 否 | `string \| null` | - | - | -| `region` | 否 | `string \| null` | - | - | -| `timezone` | 否 | `string \| null` | - | - | -| `type` | 是 | `string` | `approximate` | The type of location approximation. Always approximate. | - -### `AutoCodeInterpreterToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration for a code interpreter container. Optionally specify the IDs of the files to run the code on. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file_ids` | 否 | `array` | - | An optional list of uploaded files to make available to your code. | -| `memory_limit` | 否 | `ContainerMemoryLimit \| null` | - | - | -| `network_policy` | 否 | `ContainerNetworkPolicyDisabledParam \| ContainerNetworkPolicyAllowlistParam` | - | Network access policy for the container. | -| `type` | 是 | `string` | `auto` | Always auto. | - -### `ChatCompletionAllowedTools` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Constrains the tools available to the model to a pre-defined set. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `mode` | 是 | `string` | `auto`, `required` | Constrains the tools available to the model to a pre-defined set. auto allows the model to pick from among the allowed tools and generate a message. required requires the model to… | -| `tools` | 是 | `array` | - | A list of tool definitions that the model should be allowed to call. For the Chat Completions API, the list of tool definitions might look like: | - -### `ChatCompletionAllowedToolsChoice` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Constrains the tools available to the model to a pre-defined set. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `allowed_tools` | 是 | `ChatCompletionAllowedTools` | - | - | -| `type` | 是 | `string` | `allowed_tools` | Allowed tool configuration type. Always allowed_tools. | - -### `ChatCompletionFunctionCallOption` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Specifying a particular function via {"name": "my_function"} forces the model to call that function. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `name` | 是 | `string` | - | The name of the function to call. | - -### `ChatCompletionFunctions` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 否 | `string` | - | A description of what the function does, used by the model to choose when and how to call the function. | -| `name` | 是 | `string` | - | The name of the function to be called. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. | -| `parameters` | 否 | `FunctionParameters` | - | - | - -### `ChatCompletionMessageCustomToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A call to a custom tool created by the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `custom` | 是 | `object` | - | The custom tool that the model called. | -| `id` | 是 | `string` | - | The ID of the tool call. | -| `type` | 是 | `string` | `custom` | The type of the tool. Always custom. | - -### `ChatCompletionMessageToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A call to a function tool created by the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `function` | 是 | `object` | - | The function that the model called. | -| `id` | 是 | `string` | - | The ID of the tool call. | -| `type` | 是 | `string` | `function` | The type of the tool. Currently, only function is supported. | - -### `ChatCompletionMessageToolCallChunk` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `function` | 否 | `object` | - | - | -| `id` | 否 | `string` | - | The ID of the tool call. | -| `index` | 是 | `integer` | - | - | -| `type` | 否 | `string` | `function` | The type of the tool. Currently, only function is supported. | - -### `ChatCompletionMessageToolCalls` - -| 项 | 值 | -| --- | --- | -| 类型 | `array` | -| 说明 | The tool calls generated by the model, such as function calls. | - -### `ChatCompletionNamedToolChoice` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Specifies a tool the model should use. Use to force the model to call a specific function. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `function` | 是 | `object` | - | - | -| `type` | 是 | `string` | `function` | For function calling, the type is always function. | - -### `ChatCompletionNamedToolChoiceCustom` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Specifies a tool the model should use. Use to force the model to call a specific custom tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `custom` | 是 | `object` | - | - | -| `type` | 是 | `string` | `custom` | For custom tool calling, the type is always custom. | - -### `ChatCompletionRequestAssistantMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Messages sent by the model in response to user messages. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `audio` | 否 | `object \| null` | - | - | -| `content` | 否 | `string \| array \| null` | - | - | -| `function_call` | 否 | `object \| null` | - | - | -| `name` | 否 | `string` | - | An optional name for the participant. Provides the model information to differentiate between participants of the same role. | -| `refusal` | 否 | `string \| null` | - | - | -| `role` | 是 | `string` | `assistant` | The role of the messages author, in this case assistant. | -| `tool_calls` | 否 | `ChatCompletionMessageToolCalls` | - | - | - -### `ChatCompletionRequestAssistantMessageContentPart` - -| 项 | 值 | -| --- | --- | -| 类型 | `ChatCompletionRequestMessageContentPartText \| ChatCompletionRequestMessageContentPartRefusal` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ChatCompletionRequestMessageContentPartText` | - | -| 2 | `ChatCompletionRequestMessageContentPartRefusal` | - | - -### `ChatCompletionRequestDeveloperMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Developer-provided instructions that the model should follow, regardless of messages sent by the user. With o1 models and newer, developer messages replace the previous system mes… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| array` | - | The contents of the developer message. | -| `name` | 否 | `string` | - | An optional name for the participant. Provides the model information to differentiate between participants of the same role. | -| `role` | 是 | `string` | `developer` | The role of the messages author, in this case developer. | - -### `ChatCompletionRequestFunctionMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| null` | - | - | -| `name` | 是 | `string` | - | The name of the function to call. | -| `role` | 是 | `string` | `function` | The role of the messages author, in this case function. | - -### `ChatCompletionRequestMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `ChatCompletionRequestDeveloperMessage \| ChatCompletionRequestSystemMessage \| ChatCompletionRequestUserMessage \| ChatCompletionRequestAssistantMessage \| ChatCompletionRequestToolMessage \| ChatCompletionRequestFunctionMessage` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ChatCompletionRequestDeveloperMessage` | - | -| 2 | `ChatCompletionRequestSystemMessage` | - | -| 3 | `ChatCompletionRequestUserMessage` | - | -| 4 | `ChatCompletionRequestAssistantMessage` | - | -| 5 | `ChatCompletionRequestToolMessage` | - | -| 6 | `ChatCompletionRequestFunctionMessage` | - | - -### `ChatCompletionRequestMessageContentPartAudio` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Learn about [audio inputs](/docs/guides/audio). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `input_audio` | 是 | `object` | - | - | -| `type` | 是 | `string` | `input_audio` | The type of the content part. Always input_audio. | - -### `ChatCompletionRequestMessageContentPartFile` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Learn about [file inputs](/docs/guides/text) for text generation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file` | 是 | `object` | - | - | -| `type` | 是 | `string` | `file` | The type of the content part. Always file. | - -### `ChatCompletionRequestMessageContentPartImage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Learn about [image inputs](/docs/guides/vision). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `image_url` | 是 | `object` | - | - | -| `type` | 是 | `string` | `image_url` | The type of the content part. | - -### `ChatCompletionRequestMessageContentPartRefusal` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `refusal` | 是 | `string` | - | The refusal message generated by the model. | -| `type` | 是 | `string` | `refusal` | The type of the content part. | - -### `ChatCompletionRequestMessageContentPartText` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Learn about [text inputs](/docs/guides/text-generation). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | The text content. | -| `type` | 是 | `string` | `text` | The type of the content part. | - -### `ChatCompletionRequestSystemMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Developer-provided instructions that the model should follow, regardless of messages sent by the user. With o1 models and newer, use developer messages for this purpose instead. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| array` | - | The contents of the system message. | -| `name` | 否 | `string` | - | An optional name for the participant. Provides the model information to differentiate between participants of the same role. | -| `role` | 是 | `string` | `system` | The role of the messages author, in this case system. | - -### `ChatCompletionRequestSystemMessageContentPart` - -| 项 | 值 | -| --- | --- | -| 类型 | `ChatCompletionRequestMessageContentPartText` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ChatCompletionRequestMessageContentPartText` | - | - -### `ChatCompletionRequestToolMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| array` | - | The contents of the tool message. | -| `role` | 是 | `string` | `tool` | The role of the messages author, in this case tool. | -| `tool_call_id` | 是 | `string` | - | Tool call that this message is responding to. | - -### `ChatCompletionRequestToolMessageContentPart` - -| 项 | 值 | -| --- | --- | -| 类型 | `ChatCompletionRequestMessageContentPartText` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ChatCompletionRequestMessageContentPartText` | - | - -### `ChatCompletionRequestUserMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Messages sent by an end user, containing prompts or additional context information. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| array` | - | The contents of the user message. | -| `name` | 否 | `string` | - | An optional name for the participant. Provides the model information to differentiate between participants of the same role. | -| `role` | 是 | `string` | `user` | The role of the messages author, in this case user. | - -### `ChatCompletionRequestUserMessageContentPart` - -| 项 | 值 | -| --- | --- | -| 类型 | `ChatCompletionRequestMessageContentPartText \| ChatCompletionRequestMessageContentPartImage \| ChatCompletionRequestMessageContentPartAudio \| ChatCompletionRequestMessageContentPartFile` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ChatCompletionRequestMessageContentPartText` | - | -| 2 | `ChatCompletionRequestMessageContentPartImage` | - | -| 3 | `ChatCompletionRequestMessageContentPartAudio` | - | -| 4 | `ChatCompletionRequestMessageContentPartFile` | - | - -### `ChatCompletionResponseMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A chat completion message generated by the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `annotations` | 否 | `array` | - | Annotations for the message, when applicable, as when using the [web search tool](/docs/guides/tools-web-search?api-mode=chat). | -| `audio` | 否 | `object \| null` | - | - | -| `content` | 是 | `string \| null` | - | - | -| `function_call` | 否 | `object` | - | Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model. | -| `refusal` | 是 | `string \| null` | - | - | -| `role` | 是 | `string` | `assistant` | The role of the author of this message. | -| `tool_calls` | 否 | `ChatCompletionMessageToolCalls` | - | - | - -### `ChatCompletionStreamOptions` - -| 项 | 值 | -| --- | --- | -| 类型 | `object \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object` | Options for streaming response. Only set this when you set stream: true. | -| 2 | `null` | - | - -### `ChatCompletionStreamResponseDelta` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A chat completion delta generated by streamed model responses. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 否 | `string \| null` | - | - | -| `function_call` | 否 | `object` | - | Deprecated and replaced by tool_calls. The name and arguments of a function that should be called, as generated by the model. | -| `refusal` | 否 | `string \| null` | - | - | -| `role` | 否 | `string` | `developer`, `system`, `user`, `assistant`, `tool` | The role of the author of this message. | -| `tool_calls` | 否 | `array` | - | - | - -### `ChatCompletionTokenLogprob` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `bytes` | 是 | `array \| null` | - | - | -| `logprob` | 是 | `number` | - | The log probability of this token, if it is within the top 20 most likely tokens. Otherwise, the value -9999.0 is used to signify that the token is very unlikely. | -| `token` | 是 | `string` | - | The token. | -| `top_logprobs` | 是 | `array` | - | List of the most likely tokens and their log probability, at this token position. The number of entries may be fewer than the requested top_logprobs. | - -### `ChatCompletionTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A function tool that can be used to generate a response. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `function` | 是 | `FunctionObject` | - | - | -| `type` | 是 | `string` | `function` | The type of the tool. Currently, only function is supported. | - -### `ChatCompletionToolChoiceOption` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| ChatCompletionAllowedToolsChoice \| ChatCompletionNamedToolChoice \| ChatCompletionNamedToolChoiceCustom` | -| 说明 | Controls which (if any) tool is called by the model. none means the model will not call any tool and instead generates a message. auto means the model can pick between generating … | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | none means the model will not call any tool and instead generates a message. auto means the model can pick between generating a message or calling one or more tools. required mean… | -| 2 | `ChatCompletionAllowedToolsChoice` | - | -| 3 | `ChatCompletionNamedToolChoice` | - | -| 4 | `ChatCompletionNamedToolChoiceCustom` | - | - -### `ClickButtonType` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ClickParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A click action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `button` | 是 | `ClickButtonType` | - | Indicates which mouse button was pressed during the click. One of left, right, wheel, back, or forward. | -| `keys` | 否 | `array \| null` | - | - | -| `type` | 是 | `string` | `click` | Specifies the event type. For a click action, this property is always click. | -| `x` | 是 | `integer` | - | The x-coordinate where the click occurred. | -| `y` | 是 | `integer` | - | The y-coordinate where the click occurred. | - -### `CodeInterpreterOutputImage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The image output from the code interpreter. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `image` | The type of the output. Always image. | -| `url` | 是 | `string(uri)` | - | The URL of the image output from the code interpreter. | - -### `CodeInterpreterOutputLogs` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The logs output from the code interpreter. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `logs` | 是 | `string` | - | The logs output from the code interpreter. | -| `type` | 是 | `string` | `logs` | The type of the output. Always logs. | - -### `CodeInterpreterTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that runs Python code to help generate a response to a prompt. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `container` | 是 | `string \| AutoCodeInterpreterToolParam` | - | The code interpreter container. Can be a container ID or an object that specifies uploaded file IDs to make available to your code, along with an optional memory_limit setting. | -| `type` | 是 | `string` | `code_interpreter` | The type of the code interpreter tool. Always code_interpreter. | - -### `CodeInterpreterToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call to run code. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `code` | 是 | `string \| null` | - | - | -| `container_id` | 是 | `string` | - | The ID of the container used to run the code. | -| `id` | 是 | `string` | - | The unique ID of the code interpreter tool call. | -| `outputs` | 是 | `array \| null` | - | - | -| `status` | 是 | `string` | `in_progress`, `completed`, `incomplete`, `interpreting`, `failed` | The status of the code interpreter tool call. Valid values are in_progress, completed, incomplete, interpreting, and failed. | -| `type` | 是 | `string` | `code_interpreter_call` | The type of the code interpreter tool call. Always code_interpreter_call. | - -### `CompactResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `created_at` | 是 | `integer(unixtime)` | - | Unix timestamp (in seconds) when the compacted conversation was created. | -| `id` | 是 | `string` | - | The unique identifier for the compacted response. | -| `object` | 是 | `string` | `response.compaction` | The object type. Always response.compaction. | -| `output` | 是 | `array` | - | The compacted list of output items. | -| `usage` | 是 | `ResponseUsage` | - | Token accounting for the compaction pass, including cached, reasoning, and total tokens. | - -### `CompactResponseMethodPublicBody` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `input` | 否 | `string \| array \| null` | - | - | -| `instructions` | 否 | `string \| null` | - | - | -| `model` | 是 | `ModelIdsCompaction` | - | - | -| `previous_response_id` | 否 | `string \| null` | - | - | -| `prompt_cache_key` | 否 | `string \| null` | - | - | -| `prompt_cache_retention` | 否 | `PromptCacheRetentionEnum \| null` | - | - | -| `service_tier` | 否 | `ServiceTierEnum \| null` | - | - | - -### `CompactionBody` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A compaction item generated by the [v1/responses/compact API](/docs/api-reference/responses/compact). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `created_by` | 否 | `string` | - | The identifier of the actor that created the item. | -| `encrypted_content` | 是 | `string` | - | The encrypted content that was produced by compaction. | -| `id` | 是 | `string` | - | The unique ID of the compaction item. | -| `type` | 是 | `string` | `compaction` | The type of the item. Always compaction. | - -### `CompactionSummaryItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A compaction item generated by the [v1/responses/compact API](/docs/api-reference/responses/compact). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `encrypted_content` | 是 | `string` | - | The encrypted content of the compaction summary. | -| `id` | 否 | `string \| null` | - | - | -| `type` | 是 | `string` | `compaction` | The type of the item. Always compaction. | - -### `CompactionTriggerItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Compacts the current context. Must be the final input item. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `compaction_trigger` | The type of the item. Always compaction_trigger. | - -### `ComparisonFilter` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A filter used to compare a specified attribute key to a given value using a defined comparison operation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `key` | 是 | `string` | - | The key to compare against the value. | -| `type` | 是 | `string` | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | Specifies the comparison operator: eq, ne, gt, gte, lt, lte, in, nin. - eq: equals - ne: not equal - gt: greater than - gte: greater than or equal - lt: less than - lte: less than… | -| `value` | 是 | `string \| number \| boolean \| array` | - | The value to compare against the attribute key; supports string, number, or boolean types. | - -### `CompletionUsage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Usage statistics for the completion request. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `completion_tokens` | 是 | `integer` | - | Number of tokens in the generated completion. | -| `completion_tokens_details` | 否 | `object` | - | Breakdown of tokens used in a completion. | -| `prompt_tokens` | 是 | `integer` | - | Number of tokens in the prompt. | -| `prompt_tokens_details` | 否 | `object` | - | Breakdown of tokens used in the prompt. | -| `total_tokens` | 是 | `integer` | - | Total number of tokens used in the request (prompt + completion). | - -### `CompoundFilter` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Combine multiple filters using and or or. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `filters` | 是 | `array` | - | Array of filters to combine. Items can be ComparisonFilter or CompoundFilter. | -| `type` | 是 | `string` | `and`, `or` | Type of operation: and or or. | - -### `ComputerAction` - -| 项 | 值 | -| --- | --- | -| 类型 | `ClickParam \| DoubleClickAction \| DragParam \| KeyPressAction \| MoveParam \| ScreenshotParam \| ScrollParam \| TypeParam … (+1)` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ClickParam` | - | -| 2 | `DoubleClickAction` | - | -| 3 | `DragParam` | - | -| 4 | `KeyPressAction` | - | -| 5 | `MoveParam` | - | -| 6 | `ScreenshotParam` | - | -| 7 | `ScrollParam` | - | -| 8 | `TypeParam` | - | -| 9 | `WaitParam` | - | - -### `ComputerActionList` - -| 项 | 值 | -| --- | --- | -| 类型 | `array` | -| 说明 | Flattened batched actions for computer_use. Each action includes an type discriminator and action-specific fields. | - -### `ComputerCallOutputItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a computer tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `acknowledged_safety_checks` | 否 | `array \| null` | - | - | -| `call_id` | 是 | `string` | - | The ID of the computer tool call that produced the output. | -| `id` | 否 | `string \| null` | - | - | -| `output` | 是 | `ComputerScreenshotImage` | - | - | -| `status` | 否 | `FunctionCallItemStatus \| null` | - | - | -| `type` | 是 | `string` | `computer_call_output` | The type of the computer tool call output. Always computer_call_output. | - -### `ComputerCallOutputStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ComputerCallSafetyCheckParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A pending safety check for the computer call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `code` | 否 | `string \| null` | - | - | -| `id` | 是 | `string` | - | The ID of the pending safety check. | -| `message` | 否 | `string \| null` | - | - | - -### `ComputerEnvironment` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ComputerScreenshotContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A screenshot of a computer. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `detail` | 是 | `ImageDetail` | - | The detail level of the screenshot image to be sent to the model. One of high, low, auto, or original. Defaults to auto. | -| `file_id` | 是 | `string \| null` | - | - | -| `image_url` | 是 | `string(uri) \| null` | - | - | -| `type` | 是 | `string` | `computer_screenshot` | Specifies the event type. For a computer screenshot, this property is always set to computer_screenshot. | - -### `ComputerScreenshotImage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A computer screenshot image used with the computer use tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file_id` | 否 | `string` | - | The identifier of an uploaded file that contains the screenshot. | -| `image_url` | 否 | `string(uri)` | - | The URL of the screenshot image. | -| `type` | 是 | `string` | `computer_screenshot` | Specifies the event type. For a computer screenshot, this property is always set to computer_screenshot. | - -### `ComputerTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that controls a virtual computer. Learn more about the [computer tool](https://platform.openai.com/docs/guides/tools-computer-use). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `computer` | The type of the computer tool. Always computer. | - -### `ComputerToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call to a computer use tool. See the [computer use guide](/docs/guides/tools-computer-use) for more information. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `action` | 否 | `ComputerAction` | - | - | -| `actions` | 否 | `ComputerActionList` | - | - | -| `call_id` | 是 | `string` | - | An identifier used when responding to the tool call with output. | -| `id` | 是 | `string` | - | The unique ID of the computer call. | -| `pending_safety_checks` | 是 | `array` | - | The pending safety checks for the computer call. | -| `status` | 是 | `string` | `in_progress`, `completed`, `incomplete` | The status of the item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 是 | `string` | `computer_call` | The type of the computer call. Always computer_call. | - -### `ComputerToolCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a computer tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `acknowledged_safety_checks` | 否 | `array` | - | The safety checks reported by the API that have been acknowledged by the developer. | -| `call_id` | 是 | `string` | - | The ID of the computer tool call that produced the output. | -| `id` | 否 | `string` | - | The ID of the computer tool call output. | -| `output` | 是 | `ComputerScreenshotImage` | - | - | -| `status` | 否 | `string` | `in_progress`, `completed`, `incomplete` | The status of the message input. One of in_progress, completed, or incomplete. Populated when input items are returned via API. | -| `type` | 是 | `string` | `computer_call_output` | The type of the computer tool call output. Always computer_call_output. | - -### `ComputerToolCallOutputResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `ComputerToolCallOutput & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ComputerToolCallOutput` | - | -| 2 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `acknowledged_safety_checks` | 否 | `array` | - | `ComputerToolCallOutput` | The safety checks reported by the API that have been acknowledged by the developer. | -| `call_id` | 是 | `string` | - | `ComputerToolCallOutput` | The ID of the computer tool call that produced the output. | -| `created_by` | 否 | `string` | - | `ComputerToolCallOutputResource.allOf[2]` | The identifier of the actor that created the item. | -| `id` | 是 | `string` | - | `ComputerToolCallOutput` | The ID of the computer tool call output. | -| `output` | 是 | `ComputerScreenshotImage` | - | `ComputerToolCallOutput` | - | -| `status` | 是 | `string` | `in_progress`, `completed`, `incomplete` | `ComputerToolCallOutput` | The status of the message input. One of in_progress, completed, or incomplete. Populated when input items are returned via API. | -| `type` | 是 | `string` | `computer_call_output` | `ComputerToolCallOutput` | The type of the computer tool call output. Always computer_call_output. | - -### `ComputerUsePreviewTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that controls a virtual computer. Learn more about the [computer tool](https://platform.openai.com/docs/guides/tools-computer-use). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `display_height` | 是 | `integer` | - | The height of the computer display. | -| `display_width` | 是 | `integer` | - | The width of the computer display. | -| `environment` | 是 | `ComputerEnvironment` | - | The type of computer environment to control. | -| `type` | 是 | `string` | `computer_use_preview` | The type of the computer use tool. Always computer_use_preview. | - -### `ContainerAutoParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file_ids` | 否 | `array` | - | An optional list of uploaded files to make available to your code. | -| `memory_limit` | 否 | `ContainerMemoryLimit \| null` | - | - | -| `network_policy` | 否 | `ContainerNetworkPolicyDisabledParam \| ContainerNetworkPolicyAllowlistParam` | - | Network access policy for the container. | -| `skills` | 否 | `array` | - | An optional list of skills referenced by id or inline data. | -| `type` | 是 | `string` | `container_auto` | Automatically creates a container for this request | - -### `ContainerFileCitationBody` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A citation for a container file used to generate a model response. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `container_id` | 是 | `string` | - | The ID of the container file. | -| `end_index` | 是 | `integer` | - | The index of the last character of the container file citation in the message. | -| `file_id` | 是 | `string` | - | The ID of the file. | -| `filename` | 是 | `string` | - | The filename of the container file cited. | -| `start_index` | 是 | `integer` | - | The index of the first character of the container file citation in the message. | -| `type` | 是 | `string` | `container_file_citation` | The type of the container file citation. Always container_file_citation. | - -### `ContainerMemoryLimit` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ContainerNetworkPolicyAllowlistParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `allowed_domains` | 是 | `array` | - | A list of allowed domains when type is allowlist. | -| `domain_secrets` | 否 | `array` | - | Optional domain-scoped secrets for allowlisted domains. | -| `type` | 是 | `string` | `allowlist` | Allow outbound network access only to specified domains. Always allowlist. | - -### `ContainerNetworkPolicyDisabledParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `disabled` | Disable outbound network access. Always disabled. | - -### `ContainerNetworkPolicyDomainSecretParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `domain` | 是 | `string` | - | The domain associated with the secret. | -| `name` | 是 | `string` | - | The name of the secret to inject for the domain. | -| `value` | 是 | `string` | - | The secret value to inject for the domain. | - -### `ContainerReferenceParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `container_id` | 是 | `string` | - | The ID of the referenced container. | -| `type` | 是 | `string` | `container_reference` | References a container created with the /v1/containers endpoint | - -### `ContainerReferenceResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents a container created with /v1/containers. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `container_id` | 是 | `string` | - | - | -| `type` | 是 | `string` | `container_reference` | The environment type. Always container_reference. | - -### `ContextManagementParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `compact_threshold` | 否 | `integer \| null` | - | - | -| `type` | 是 | `string` | - | The context management entry type. Currently only 'compaction' is supported. | - -### `Conversation-2` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The conversation that this response belonged to. Input items and output items from this response were automatically added to this conversation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The unique ID of the conversation that this response was associated with. | - -### `ConversationParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| ConversationParam-2` | -| 说明 | The conversation that this response belongs to. Items from this conversation are prepended to input_items for this response request. Input items and output items from this respons… | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | The unique ID of the conversation. | -| 2 | `ConversationParam-2` | - | - -### `ConversationParam-2` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The conversation that this response belongs to. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The unique ID of the conversation. | - -### `CoordParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An x/y coordinate pair, e.g. { x: 100, y: 200 }. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `x` | 是 | `integer` | - | The x-coordinate. | -| `y` | 是 | `integer` | - | The y-coordinate. | - -### `CreateChatCompletionRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `CreateModelResponseProperties & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `CreateModelResponseProperties` | - | -| 2 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `audio` | 否 | `object` | - | `CreateChatCompletionRequest.allOf[2]` | Parameters for audio output. Required when audio output is requested with modalities: ["audio"]. [Learn more](/docs/guides/audio). | -| `frequency_penalty` | 否 | `number` | - | `CreateChatCompletionRequest.allOf[2]` | Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line ve… | -| `function_call` | 否 | `string \| ChatCompletionFunctionCallOption` | - | `CreateChatCompletionRequest.allOf[2]` | Deprecated in favor of tool_choice. Controls which (if any) function is called by the model. none means the model will not call a function and instead generates a message. auto me… | -| `functions` | 否 | `array` | - | `CreateChatCompletionRequest.allOf[2]` | Deprecated in favor of tools. A list of functions the model may generate JSON inputs for. | -| `logit_bias` | 否 | `object/map` | - | `CreateChatCompletionRequest.allOf[2]` | Modify the likelihood of specified tokens appearing in the completion. Accepts a JSON object that maps tokens (specified by their token ID in the tokenizer) to an associated bias … | -| `logprobs` | 否 | `boolean` | - | `CreateChatCompletionRequest.allOf[2]` | Whether to return log probabilities of the output tokens or not. If true, returns the log probabilities of each output token returned in the content of message. | -| `max_completion_tokens` | 否 | `integer` | - | `CreateChatCompletionRequest.allOf[2]` | An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and [reasoning tokens](/docs/guides/reasoning). | -| `max_tokens` | 否 | `integer` | - | `CreateChatCompletionRequest.allOf[2]` | The maximum number of [tokens](/tokenizer) that can be generated in the chat completion. This value can be used to control [costs](https://openai.com/api/pricing/) for text genera… | -| `messages` | 是 | `array` | - | `CreateChatCompletionRequest.allOf[2]` | A list of messages comprising the conversation so far. Depending on the [model](/docs/models) you use, different message types (modalities) are supported, like [text](/docs/guides… | -| `metadata` | 否 | `Metadata` | - | `ModelResponseProperties` | - | -| `modalities` | 否 | `ResponseModalities` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `model` | 是 | `ModelIdsShared` | - | `CreateChatCompletionRequest.allOf[2]` | Model ID used to generate the response, like gpt-4o or o3. OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points. Refer t… | -| `n` | 否 | `integer` | - | `CreateChatCompletionRequest.allOf[2]` | How many chat completion choices to generate for each input message. Note that you will be charged based on the number of generated tokens across all of the choices. Keep n as 1 t… | -| `parallel_tool_calls` | 否 | `ParallelToolCalls` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `prediction` | 否 | `PredictionContent` | - | `CreateChatCompletionRequest.allOf[2]` | Configuration for a [Predicted Output](/docs/guides/predicted-outputs), which can greatly improve response times when large parts of the model response are known ahead of time. Th… | -| `presence_penalty` | 否 | `number` | - | `CreateChatCompletionRequest.allOf[2]` | Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics. | -| `prompt_cache_key` | 否 | `string` | - | `ModelResponseProperties` | Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field. [Learn more](/docs/guides/prompt-caching). | -| `prompt_cache_retention` | 否 | `string \| null` | - | `ModelResponseProperties` | - | -| `reasoning_effort` | 否 | `ReasoningEffort` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `response_format` | 否 | `ResponseFormatText \| ResponseFormatJsonSchema \| ResponseFormatJsonObject` | - | `CreateChatCompletionRequest.allOf[2]` | An object specifying the format that the model must output. Setting to { "type": "json_schema", "json_schema": {...} } enables Structured Outputs which ensures the model will matc… | -| `safety_identifier` | 否 | `string` | - | `ModelResponseProperties` | A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user, wit… | -| `seed` | 否 | `integer` | - | `CreateChatCompletionRequest.allOf[2]` | This feature is in Beta. If specified, our system will make a best effort to sample deterministically, such that repeated requests with the same seed and parameters should return … | -| `service_tier` | 否 | `ServiceTier` | - | `ModelResponseProperties` | - | -| `stop` | 否 | `StopConfiguration` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `store` | 否 | `boolean` | - | `CreateChatCompletionRequest.allOf[2]` | Whether or not to store the output of this chat completion request for use in our [model distillation](/docs/guides/distillation) or [evals](/docs/guides/evals) products. Supports… | -| `stream` | 否 | `boolean` | - | `CreateChatCompletionRequest.allOf[2]` | If set to true, the model response data will be streamed to the client as it is generated using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_e… | -| `stream_options` | 否 | `ChatCompletionStreamOptions` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `temperature` | 否 | `number \| null` | - | `ModelResponseProperties` | - | -| `tool_choice` | 否 | `ChatCompletionToolChoiceOption` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `tools` | 否 | `array` | - | `CreateChatCompletionRequest.allOf[2]` | A list of tools the model may call. You can provide either [custom tools](/docs/guides/function-calling#custom-tools) or [function tools](/docs/guides/function-calling). | -| `top_logprobs` | 否 | `integer \| null` | - | `ModelResponseProperties` | - | -| `top_p` | 否 | `number \| null` | - | `ModelResponseProperties` | - | -| `user` | 否 | `string` | - | `ModelResponseProperties` | This field is being replaced by safety_identifier and prompt_cache_key. Use prompt_cache_key instead to maintain caching optimizations. A stable identifier for your end-users. Use… | -| `verbosity` | 否 | `Verbosity` | - | `CreateChatCompletionRequest.allOf[2]` | - | -| `web_search_options` | 否 | `object` | - | `CreateChatCompletionRequest.allOf[2]` | This tool searches the web for relevant results to use in a response. Learn more about the [web search tool](/docs/guides/tools-web-search?api-mode=chat). | - -### `CreateChatCompletionResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents a chat completion response returned by model, based on the provided input. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `choices` | 是 | `array` | - | A list of chat completion choices. Can be more than one if n is greater than 1. | -| `created` | 是 | `integer(unixtime)` | - | The Unix timestamp (in seconds) of when the chat completion was created. | -| `id` | 是 | `string` | - | A unique identifier for the chat completion. | -| `model` | 是 | `string` | - | The model used for the chat completion. | -| `object` | 是 | `string` | `chat.completion` | The object type, which is always chat.completion. | -| `service_tier` | 否 | `ServiceTier` | - | - | -| `system_fingerprint` | 否 | `string` | - | This fingerprint represents the backend configuration that the model runs with. Can be used in conjunction with the seed request parameter to understand when backend changes have … | -| `usage` | 否 | `CompletionUsage` | - | - | - -### `CreateChatCompletionStreamResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents a streamed chunk of a chat completion response returned by the model, based on the provided input. [Learn more](/docs/guides/streaming-responses). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `choices` | 是 | `array` | - | A list of chat completion choices. Can contain more than one elements if n is greater than 1. Can also be empty for the last chunk if you set stream_options: {"include_usage": tru… | -| `created` | 是 | `integer(unixtime)` | - | The Unix timestamp (in seconds) of when the chat completion was created. Each chunk has the same timestamp. | -| `id` | 是 | `string` | - | A unique identifier for the chat completion. Each chunk has the same ID. | -| `model` | 是 | `string` | - | The model to generate the completion. | -| `object` | 是 | `string` | `chat.completion.chunk` | The object type, which is always chat.completion.chunk. | -| `service_tier` | 否 | `ServiceTier` | - | - | -| `system_fingerprint` | 否 | `string` | - | This fingerprint represents the backend configuration that the model runs with. Can be used in conjunction with the seed request parameter to understand when backend changes have … | -| `usage` | 否 | `CompletionUsage` | - | An optional field that will only be present when you set stream_options: {"include_usage": true} in your request. When present, it contains a null value **except for the last chun… | - -### `CreateEmbeddingRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `dimensions` | 否 | `integer` | - | The number of dimensions the resulting output embeddings should have. Only supported in text-embedding-3 and later models. | -| `encoding_format` | 否 | `string` | `float`, `base64` | The format to return the embeddings in. Can be either float or [base64](https://pypi.org/project/pybase64/). | -| `input` | 是 | `string \| array \| array \| array>` | - | Input text to embed, encoded as a string or array of tokens. To embed multiple inputs in a single request, pass an array of strings or array of token arrays. The input must not ex… | -| `model` | 是 | `string \| string` | - | ID of the model to use. You can use the [List models](/docs/api-reference/models/list) API to see all of your available models, or see our [Model overview](/docs/models) for descr… | -| `user` | 否 | `string` | - | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. [Learn more](/docs/guides/safety-best-practices#end-user-ids). | - -### `CreateEmbeddingResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `data` | 是 | `array` | - | The list of embeddings generated by the model. | -| `model` | 是 | `string` | - | The name of the model used to generate the embedding. | -| `object` | 是 | `string` | `list` | The object type, which is always "list". | -| `usage` | 是 | `object` | - | The usage information for the request. | - -### `CreateImageEditRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `background` | 否 | `string` | `transparent`, `opaque`, `auto` | Allows to set transparency for the background of the generated image(s). This parameter is only supported for the GPT image models. Must be one of transparent, opaque or auto (def… | -| `image` | 是 | `string(binary) \| array` | - | The image(s) to edit. Must be a supported image file or an array of images. For the GPT image models (gpt-image-1, gpt-image-1-mini, and gpt-image-1.5), each image should be a png… | -| `input_fidelity` | 否 | `InputFidelity \| null` | - | - | -| `mask` | 否 | `string(binary)` | - | An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where image should be edited. If there are multiple images provided, the mask will be applied… | -| `model` | 否 | `string \| string` | - | The model to use for image generation. Defaults to gpt-image-1.5. | -| `n` | 否 | `integer` | - | The number of images to generate. Must be between 1 and 10. | -| `output_compression` | 否 | `integer` | - | The compression level (0-100%) for the generated images. This parameter is only supported for the GPT image models with the webp or jpeg output formats, and defaults to 100. | -| `output_format` | 否 | `string` | `png`, `jpeg`, `webp` | The format in which the generated images are returned. This parameter is only supported for the GPT image models. Must be one of png, jpeg, or webp. The default value is png. | -| `partial_images` | 否 | `PartialImages` | - | - | -| `prompt` | 是 | `string` | - | A text description of the desired image(s). The maximum length is 1000 characters for dall-e-2, and 32000 characters for the GPT image models. | -| `quality` | 否 | `string` | `standard`, `low`, `medium`, `high`, `auto` | The quality of the image that will be generated for GPT image models. Defaults to auto. | -| `response_format` | 否 | `string` | `url`, `b64_json` | The format in which the generated images are returned. Must be one of url or b64_json. URLs are only valid for 60 minutes after the image has been generated. This parameter is onl… | -| `size` | 否 | `string \| string` | - | The size of the generated images. For gpt-image-2 and gpt-image-2-2026-04-21, arbitrary resolutions are supported as WIDTHxHEIGHT strings, for example 1536x864. Width and height m… | -| `stream` | 否 | `boolean` | - | Edit the image in streaming mode. Defaults to false. See the [Image generation guide](/docs/guides/image-generation) for more information. | -| `user` | 否 | `string` | - | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. [Learn more](/docs/guides/safety-best-practices#end-user-ids). | - -### `CreateImageRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `background` | 否 | `string` | `transparent`, `opaque`, `auto` | Allows to set transparency for the background of the generated image(s). This parameter is only supported for the GPT image models. Must be one of transparent, opaque or auto (def… | -| `model` | 否 | `string \| string` | - | The model to use for image generation. One of dall-e-2, dall-e-3, or a GPT image model (gpt-image-1, gpt-image-1-mini, gpt-image-1.5). Defaults to dall-e-2 unless a parameter spec… | -| `moderation` | 否 | `string` | `low`, `auto` | Control the content-moderation level for images generated by the GPT image models. Must be either low for less restrictive filtering or auto (default value). | -| `n` | 否 | `integer` | - | The number of images to generate. Must be between 1 and 10. For dall-e-3, only n=1 is supported. | -| `output_compression` | 否 | `integer` | - | The compression level (0-100%) for the generated images. This parameter is only supported for the GPT image models with the webp or jpeg output formats, and defaults to 100. | -| `output_format` | 否 | `string` | `png`, `jpeg`, `webp` | The format in which the generated images are returned. This parameter is only supported for the GPT image models. Must be one of png, jpeg, or webp. | -| `partial_images` | 否 | `PartialImages` | - | - | -| `prompt` | 是 | `string` | - | A text description of the desired image(s). The maximum length is 32000 characters for the GPT image models, 1000 characters for dall-e-2 and 4000 characters for dall-e-3. | -| `quality` | 否 | `string` | `standard`, `hd`, `low`, `medium`, `high`, `auto` | The quality of the image that will be generated. - auto (default value) will automatically select the best quality for the given model. - high, medium and low are supported for th… | -| `response_format` | 否 | `string` | `url`, `b64_json` | The format in which generated images with dall-e-2 and dall-e-3 are returned. Must be one of url or b64_json. URLs are only valid for 60 minutes after the image has been generated… | -| `size` | 否 | `string \| string` | - | The size of the generated images. For gpt-image-2 and gpt-image-2-2026-04-21, arbitrary resolutions are supported as WIDTHxHEIGHT strings, for example 1536x864. Width and height m… | -| `stream` | 否 | `boolean` | - | Generate the image in streaming mode. Defaults to false. See the [Image generation guide](/docs/guides/image-generation) for more information. This parameter is only supported for… | -| `style` | 否 | `string` | `vivid`, `natural` | The style of the generated images. This parameter is only supported for dall-e-3. Must be one of vivid or natural. Vivid causes the model to lean towards generating hyper-real and… | -| `user` | 否 | `string` | - | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. [Learn more](/docs/guides/safety-best-practices#end-user-ids). | - -### `CreateImageVariationRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `image` | 是 | `string(binary)` | - | The image to use as the basis for the variation(s). Must be a valid PNG file, less than 4MB, and square. | -| `model` | 否 | `string \| string` | - | The model to use for image generation. Only dall-e-2 is supported at this time. | -| `n` | 否 | `integer` | - | The number of images to generate. Must be between 1 and 10. | -| `response_format` | 否 | `string` | `url`, `b64_json` | The format in which the generated images are returned. Must be one of url or b64_json. URLs are only valid for 60 minutes after the image has been generated. | -| `size` | 否 | `string` | `256x256`, `512x512`, `1024x1024` | The size of the generated images. Must be one of 256x256, 512x512, or 1024x1024. | -| `user` | 否 | `string` | - | A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. [Learn more](/docs/guides/safety-best-practices#end-user-ids). | - -### `CreateModelResponseProperties` - -| 项 | 值 | -| --- | --- | -| 类型 | `ModelResponseProperties & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ModelResponseProperties` | - | -| 2 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `metadata` | 否 | `Metadata` | - | `ModelResponseProperties` | - | -| `prompt_cache_key` | 否 | `string` | - | `ModelResponseProperties` | Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field. [Learn more](/docs/guides/prompt-caching). | -| `prompt_cache_retention` | 否 | `string \| null` | - | `ModelResponseProperties` | - | -| `safety_identifier` | 否 | `string` | - | `ModelResponseProperties` | A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user, wit… | -| `service_tier` | 否 | `ServiceTier` | - | `ModelResponseProperties` | - | -| `temperature` | 否 | `number \| null` | - | `ModelResponseProperties` | - | -| `top_logprobs` | 否 | `integer \| null` | - | `ModelResponseProperties` | - | -| `top_p` | 否 | `number \| null` | - | `ModelResponseProperties` | - | -| `user` | 否 | `string` | - | `ModelResponseProperties` | This field is being replaced by safety_identifier and prompt_cache_key. Use prompt_cache_key instead to maintain caching optimizations. A stable identifier for your end-users. Use… | - -### `CreateResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `CreateModelResponseProperties & ResponseProperties & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `CreateModelResponseProperties` | - | -| 2 | `ResponseProperties` | - | -| 3 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `background` | 否 | `boolean \| null` | - | `ResponseProperties` | - | -| `context_management` | 否 | `array \| null` | - | `CreateResponse.allOf[3]` | - | -| `conversation` | 否 | `ConversationParam \| null` | - | `CreateResponse.allOf[3]` | - | -| `include` | 否 | `array \| null` | - | `CreateResponse.allOf[3]` | - | -| `input` | 否 | `InputParam` | - | `CreateResponse.allOf[3]` | - | -| `instructions` | 否 | `string \| null` | - | `CreateResponse.allOf[3]` | - | -| `max_output_tokens` | 否 | `integer \| null` | - | `CreateResponse.allOf[3]` | - | -| `max_tool_calls` | 否 | `integer \| null` | - | `ResponseProperties` | - | -| `metadata` | 否 | `Metadata` | - | `ModelResponseProperties` | - | -| `model` | 否 | `ModelIdsResponses` | - | `ResponseProperties` | Model ID used to generate the response, like gpt-4o or o3. OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points. Refer t… | -| `parallel_tool_calls` | 否 | `boolean \| null` | - | `CreateResponse.allOf[3]` | - | -| `previous_response_id` | 否 | `string \| null` | - | `ResponseProperties` | - | -| `prompt` | 否 | `Prompt` | - | `ResponseProperties` | - | -| `prompt_cache_key` | 否 | `string` | - | `ModelResponseProperties` | Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field. [Learn more](/docs/guides/prompt-caching). | -| `prompt_cache_retention` | 否 | `string \| null` | - | `ModelResponseProperties` | - | -| `reasoning` | 否 | `Reasoning \| null` | - | `ResponseProperties` | - | -| `safety_identifier` | 否 | `string` | - | `ModelResponseProperties` | A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user, wit… | -| `service_tier` | 否 | `ServiceTier` | - | `ModelResponseProperties` | - | -| `store` | 否 | `boolean \| null` | - | `CreateResponse.allOf[3]` | - | -| `stream` | 否 | `boolean \| null` | - | `CreateResponse.allOf[3]` | - | -| `stream_options` | 否 | `ResponseStreamOptions` | - | `CreateResponse.allOf[3]` | - | -| `temperature` | 否 | `number \| null` | - | `ModelResponseProperties` | - | -| `text` | 否 | `ResponseTextParam` | - | `ResponseProperties` | - | -| `tool_choice` | 否 | `ToolChoiceParam` | - | `ResponseProperties` | - | -| `tools` | 否 | `ToolsArray` | - | `ResponseProperties` | - | -| `top_logprobs` | 否 | `integer \| null` | - | `ModelResponseProperties` | - | -| `top_p` | 否 | `number \| null` | - | `ModelResponseProperties` | - | -| `truncation` | 否 | `string \| null` | - | `ResponseProperties` | - | -| `user` | 否 | `string` | - | `ModelResponseProperties` | This field is being replaced by safety_identifier and prompt_cache_key. Use prompt_cache_key instead to maintain caching optimizations. A stable identifier for your end-users. Use… | - -### `CustomGrammarFormatParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A grammar defined by the user. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `definition` | 是 | `string` | - | The grammar definition. | -| `syntax` | 是 | `GrammarSyntax1` | - | The syntax of the grammar definition. One of lark or regex. | -| `type` | 是 | `string` | `grammar` | Grammar format. Always grammar. | - -### `CustomTextFormatParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Unconstrained free-form text. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `text` | Unconstrained text format. Always text. | - -### `CustomToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A call to a custom tool created by the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | An identifier used to map this custom tool call to a tool call output. | -| `id` | 否 | `string` | - | The unique ID of the custom tool call in the OpenAI platform. | -| `input` | 是 | `string` | - | The input for the custom tool call generated by the model. | -| `name` | 是 | `string` | - | The name of the custom tool being called. | -| `namespace` | 否 | `string` | - | The namespace of the custom tool being called. | -| `type` | 是 | `string` | `custom_tool_call` | The type of the custom tool call. Always custom_tool_call. | - -### `CustomToolCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a custom tool call from your code, being sent back to the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The call ID, used to map this custom tool call output to a custom tool call. | -| `id` | 否 | `string` | - | The unique ID of the custom tool call output in the OpenAI platform. | -| `output` | 是 | `string \| array` | - | The output from the custom tool call generated by your code. Can be a string or an list of output content. | -| `type` | 是 | `string` | `custom_tool_call_output` | The type of the custom tool call output. Always custom_tool_call_output. | - -### `CustomToolCallOutputResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `CustomToolCallOutput & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `CustomToolCallOutput` | - | -| 2 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | `CustomToolCallOutput` | The call ID, used to map this custom tool call output to a custom tool call. | -| `created_by` | 否 | `string` | - | `CustomToolCallOutputResource.allOf[2]` | The identifier of the actor that created the item. | -| `id` | 是 | `string` | - | `CustomToolCallOutput` | The unique ID of the custom tool call output in the OpenAI platform. | -| `output` | 是 | `string \| array` | - | `CustomToolCallOutput` | The output from the custom tool call generated by your code. Can be a string or an list of output content. | -| `status` | 是 | `FunctionCallOutputStatusEnum` | - | `CustomToolCallOutputResource.allOf[2]` | The status of the item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 是 | `string` | `custom_tool_call_output` | `CustomToolCallOutput` | The type of the custom tool call output. Always custom_tool_call_output. | - -### `CustomToolChatCompletions` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A custom tool that processes input using a specified format. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `custom` | 是 | `object` | - | Properties of the custom tool. | -| `type` | 是 | `string` | `custom` | The type of the custom tool. Always custom. | - -### `CustomToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A custom tool that processes input using a specified format. Learn more about [custom tools](/docs/guides/function-calling#custom-tools) | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `defer_loading` | 否 | `boolean` | - | Whether this tool should be deferred and discovered via tool search. | -| `description` | 否 | `string` | - | Optional description of the custom tool, used to provide more context. | -| `format` | 否 | `CustomTextFormatParam \| CustomGrammarFormatParam` | - | The input format for the custom tool. Default is unconstrained text. | -| `name` | 是 | `string` | - | The name of the custom tool, used to identify it in tool calls. | -| `type` | 是 | `string` | `custom` | The type of the custom tool. Always custom. | - -### `DetailEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `DoubleClickAction` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A double click action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `keys` | 是 | `array \| null` | - | - | -| `type` | 是 | `string` | `double_click` | Specifies the event type. For a double click action, this property is always set to double_click. | -| `x` | 是 | `integer` | - | The x-coordinate where the double click occurred. | -| `y` | 是 | `integer` | - | The y-coordinate where the double click occurred. | - -### `DragParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A drag action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `keys` | 否 | `array \| null` | - | - | -| `path` | 是 | `array` | - | An array of coordinates representing the path of the drag action. Coordinates will appear as an array of objects, eg | -| `type` | 是 | `string` | `drag` | Specifies the event type. For a drag action, this property is always set to drag. | - -### `EasyInputMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A message input to the model with a role indicating instruction following hierarchy. Instructions given with the developer or system role take precedence over instructions given w… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| InputMessageContentList` | - | Text, image, or audio input to the model, used to generate a response. Can also contain previous assistant responses. | -| `phase` | 否 | `MessagePhase \| null` | - | - | -| `role` | 是 | `string` | `user`, `assistant`, `system`, `developer` | The role of the message input. One of user, assistant, system, or developer. | -| `type` | 否 | `string` | `message` | The type of the message input. Always message. | - -### `EditImageBodyJsonParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | JSON request body for image edits. Use images (array of ImageRefParam) instead of multipart image uploads. You can reference images via external URLs, data URLs, or uploaded file … | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `background` | 否 | `string \| null` | - | Background behavior for generated image output. | -| `images` | 是 | `array` | - | Input image references to edit. For GPT image models, you can provide up to 16 images. | -| `input_fidelity` | 否 | `string \| null` | - | Controls fidelity to the original input image(s). | -| `mask` | 否 | `ImageRefParam` | - | - | -| `model` | 否 | `string \| string \| null` | - | The model to use for image editing. | -| `moderation` | 否 | `string \| null` | - | Moderation level for GPT image models. | -| `n` | 否 | `integer \| null` | - | The number of edited images to generate. | -| `output_compression` | 否 | `integer \| null` | - | Compression level for jpeg or webp output. | -| `output_format` | 否 | `string \| null` | - | Output image format. Supported for GPT image models. | -| `partial_images` | 否 | `PartialImages` | - | - | -| `prompt` | 是 | `string` | - | A text description of the desired image edit. | -| `quality` | 否 | `string \| null` | - | Output quality for GPT image models. | -| `size` | 否 | `string \| null` | - | Requested output image size. | -| `stream` | 否 | `boolean \| null` | - | Stream partial image results as events. | -| `user` | 否 | `string` | - | A unique identifier representing your end-user, which can help OpenAI monitor and detect abuse. | - -### `Embedding` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents an embedding vector returned by embedding endpoint. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `embedding` | 是 | `array` | - | The embedding vector, which is a list of floats. The length of vector depends on the model as listed in the [embedding guide](/docs/guides/embeddings). | -| `index` | 是 | `integer` | - | The index of the embedding in the list of embeddings. | -| `object` | 是 | `string` | `embedding` | The object type, which is always "embedding". | - -### `EmptyModelParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -### `FileCitationBody` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A citation to a file. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file_id` | 是 | `string` | - | The ID of the file. | -| `filename` | 是 | `string` | - | The filename of the file cited. | -| `index` | 是 | `integer` | - | The index of the file in the list of files. | -| `type` | 是 | `string` | `file_citation` | The type of the file citation. Always file_citation. | - -### `FileDetailEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FileInputDetail` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FilePath` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A path to a file. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file_id` | 是 | `string` | - | The ID of the file. | -| `index` | 是 | `integer` | - | The index of the file in the list of files. | -| `type` | 是 | `string` | `file_path` | The type of the file path. Always file_path. | - -### `FileSearchTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that searches for relevant content from uploaded files. Learn more about the [file search tool](https://platform.openai.com/docs/guides/tools-file-search). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `filters` | 否 | `Filters \| null` | - | - | -| `max_num_results` | 否 | `integer` | - | The maximum number of results to return. This number should be between 1 and 50 inclusive. | -| `ranking_options` | 否 | `RankingOptions` | - | Ranking options for search. | -| `type` | 是 | `string` | `file_search` | The type of the file search tool. Always file_search. | -| `vector_store_ids` | 是 | `array` | - | The IDs of the vector stores to search. | - -### `FileSearchToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The results of a file search tool call. See the [file search guide](/docs/guides/tools-file-search) for more information. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The unique ID of the file search tool call. | -| `queries` | 是 | `array` | - | The queries used to search for files. | -| `results` | 否 | `array \| null` | - | - | -| `status` | 是 | `string` | `in_progress`, `searching`, `completed`, `incomplete`, `failed` | The status of the file search tool call. One of in_progress, searching, incomplete or failed, | -| `type` | 是 | `string` | `file_search_call` | The type of the file search tool call. Always file_search_call. | - -### `Filters` - -| 项 | 值 | -| --- | --- | -| 类型 | `ComparisonFilter \| CompoundFilter` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ComparisonFilter` | - | -| 2 | `CompoundFilter` | - | - -### `FunctionAndCustomToolCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `InputTextContent \| InputImageContent \| InputFileContent` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `InputTextContent` | - | -| 2 | `InputImageContent` | - | -| 3 | `InputFileContent` | - | - -### `FunctionCallItemStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FunctionCallOutputItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a function tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the function tool call generated by the model. | -| `id` | 否 | `string \| null` | - | - | -| `output` | 是 | `string \| array` | - | Text, image, or file output of the function tool call. | -| `status` | 否 | `FunctionCallItemStatus \| null` | - | - | -| `type` | 是 | `string` | `function_call_output` | The type of the function tool call output. Always function_call_output. | - -### `FunctionCallOutputStatusEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FunctionCallStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FunctionObject` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 否 | `string` | - | A description of what the function does, used by the model to choose when and how to call the function. | -| `name` | 是 | `string` | - | The name of the function to be called. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. | -| `parameters` | 否 | `FunctionParameters` | - | - | -| `strict` | 否 | `boolean \| null` | - | - | - -### `FunctionParameters` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The parameters the functions accepts, described as a JSON Schema object. See the [guide](/docs/guides/function-calling) for examples, and the [JSON Schema reference](https://json-… | - -Additional properties: `任意 JSON 值` - -### `FunctionShellAction` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Execute a shell command. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `commands` | 是 | `array` | - | - | -| `max_output_length` | 是 | `integer \| null` | - | - | -| `timeout_ms` | 是 | `integer \| null` | - | - | - -### `FunctionShellActionParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Commands and limits describing how to run the shell tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `commands` | 是 | `array` | - | Ordered shell commands for the execution environment to run. | -| `max_output_length` | 否 | `integer \| null` | - | - | -| `timeout_ms` | 否 | `integer \| null` | - | - | - -### `FunctionShellCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call that executes one or more shell commands in a managed environment. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `action` | 是 | `FunctionShellAction` | - | The shell commands and limits that describe how to run the tool call. | -| `call_id` | 是 | `string` | - | The unique ID of the shell tool call generated by the model. | -| `created_by` | 否 | `string` | - | The ID of the entity that created this tool call. | -| `environment` | 是 | `LocalEnvironmentResource \| ContainerReferenceResource \| null` | - | - | -| `id` | 是 | `string` | - | The unique ID of the shell tool call. Populated when this item is returned via API. | -| `status` | 是 | `FunctionShellCallStatus` | - | The status of the shell call. One of in_progress, completed, or incomplete. | -| `type` | 是 | `string` | `shell_call` | The type of the item. Always shell_call. | - -### `FunctionShellCallItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool representing a request to execute one or more shell commands. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `action` | 是 | `FunctionShellActionParam` | - | The shell commands and limits that describe how to run the tool call. | -| `call_id` | 是 | `string` | - | The unique ID of the shell tool call generated by the model. | -| `environment` | 否 | `LocalEnvironmentParam \| ContainerReferenceParam \| null` | - | - | -| `id` | 否 | `string \| null` | - | - | -| `status` | 否 | `FunctionShellCallItemStatus \| null` | - | - | -| `type` | 是 | `string` | `shell_call` | The type of the item. Always shell_call. | - -### `FunctionShellCallItemStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Status values reported for shell tool calls. | - -### `FunctionShellCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a shell tool call that was emitted. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the shell tool call generated by the model. | -| `created_by` | 否 | `string` | - | The identifier of the actor that created the item. | -| `id` | 是 | `string` | - | The unique ID of the shell call output. Populated when this item is returned via API. | -| `max_output_length` | 是 | `integer \| null` | - | - | -| `output` | 是 | `array` | - | An array of shell call output contents | -| `status` | 是 | `FunctionShellCallOutputStatusEnum` | - | The status of the shell call output. One of in_progress, completed, or incomplete. | -| `type` | 是 | `string` | `shell_call_output` | The type of the shell call output. Always shell_call_output. | - -### `FunctionShellCallOutputContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The content of a shell tool call output that was emitted. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `created_by` | 否 | `string` | - | The identifier of the actor that created the item. | -| `outcome` | 是 | `FunctionShellCallOutputTimeoutOutcome \| FunctionShellCallOutputExitOutcome` | - | Represents either an exit outcome (with an exit code) or a timeout outcome for a shell call output chunk. | -| `stderr` | 是 | `string` | - | The standard error output that was captured. | -| `stdout` | 是 | `string` | - | The standard output that was captured. | - -### `FunctionShellCallOutputContentParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Captured stdout and stderr for a portion of a shell tool call output. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `outcome` | 是 | `FunctionShellCallOutputOutcomeParam` | - | The exit or timeout outcome associated with this shell call. | -| `stderr` | 是 | `string` | - | Captured stderr output for the shell call. | -| `stdout` | 是 | `string` | - | Captured stdout output for the shell call. | - -### `FunctionShellCallOutputExitOutcome` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Indicates that the shell commands finished and returned an exit code. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `exit_code` | 是 | `integer` | - | Exit code from the shell process. | -| `type` | 是 | `string` | `exit` | The outcome type. Always exit. | - -### `FunctionShellCallOutputExitOutcomeParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Indicates that the shell commands finished and returned an exit code. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `exit_code` | 是 | `integer` | - | The exit code returned by the shell process. | -| `type` | 是 | `string` | `exit` | The outcome type. Always exit. | - -### `FunctionShellCallOutputItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The streamed output items emitted by a shell tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the shell tool call generated by the model. | -| `id` | 否 | `string \| null` | - | - | -| `max_output_length` | 否 | `integer \| null` | - | - | -| `output` | 是 | `array` | - | Captured chunks of stdout and stderr output, along with their associated outcomes. | -| `status` | 否 | `FunctionShellCallItemStatus \| null` | - | - | -| `type` | 是 | `string` | `shell_call_output` | The type of the item. Always shell_call_output. | - -### `FunctionShellCallOutputOutcomeParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `FunctionShellCallOutputTimeoutOutcomeParam \| FunctionShellCallOutputExitOutcomeParam` | -| 说明 | The exit or timeout outcome associated with this shell call. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `FunctionShellCallOutputTimeoutOutcomeParam` | - | -| 2 | `FunctionShellCallOutputExitOutcomeParam` | - | - -### `FunctionShellCallOutputStatusEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FunctionShellCallOutputTimeoutOutcome` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Indicates that the shell call exceeded its configured time limit. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `timeout` | The outcome type. Always timeout. | - -### `FunctionShellCallOutputTimeoutOutcomeParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Indicates that the shell call exceeded its configured time limit. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `timeout` | The outcome type. Always timeout. | - -### `FunctionShellCallStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `FunctionShellToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that allows the model to execute shell commands. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `environment` | 否 | `ContainerAutoParam \| LocalEnvironmentParam \| ContainerReferenceParam \| null` | - | - | -| `type` | 是 | `string` | `shell` | The type of the shell tool. Always shell. | - -### `FunctionTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Defines a function in your own code the model can choose to call. Learn more about [function calling](https://platform.openai.com/docs/guides/function-calling). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `defer_loading` | 否 | `boolean` | - | Whether this function is deferred and loaded via tool search. | -| `description` | 否 | `string \| null` | - | - | -| `name` | 是 | `string` | - | The name of the function to call. | -| `parameters` | 是 | `object/map \| null` | - | - | -| `strict` | 是 | `boolean \| null` | - | - | -| `type` | 是 | `string` | `function` | The type of the function tool. Always function. | - -### `FunctionToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call to run a function. See the [function calling guide](/docs/guides/function-calling) for more information. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `arguments` | 是 | `string` | - | A JSON string of the arguments to pass to the function. | -| `call_id` | 是 | `string` | - | The unique ID of the function tool call generated by the model. | -| `id` | 否 | `string` | - | The unique ID of the function tool call. | -| `name` | 是 | `string` | - | The name of the function to run. | -| `namespace` | 否 | `string` | - | The namespace of the function to run. | -| `status` | 否 | `string` | `in_progress`, `completed`, `incomplete` | The status of the item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 是 | `string` | `function_call` | The type of the function tool call. Always function_call. | - -### `FunctionToolCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a function tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | The unique ID of the function tool call generated by the model. | -| `id` | 否 | `string` | - | The unique ID of the function tool call output. Populated when this item is returned via API. | -| `output` | 是 | `string \| array` | - | The output from the function call generated by your code. Can be a string or an list of output content. | -| `status` | 否 | `string` | `in_progress`, `completed`, `incomplete` | The status of the item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 是 | `string` | `function_call_output` | The type of the function tool call output. Always function_call_output. | - -### `FunctionToolCallOutputResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `FunctionToolCallOutput & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `FunctionToolCallOutput` | - | -| 2 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `call_id` | 是 | `string` | - | `FunctionToolCallOutput` | The unique ID of the function tool call generated by the model. | -| `created_by` | 否 | `string` | - | `FunctionToolCallOutputResource.allOf[2]` | The identifier of the actor that created the item. | -| `id` | 是 | `string` | - | `FunctionToolCallOutput` | The unique ID of the function tool call output. Populated when this item is returned via API. | -| `output` | 是 | `string \| array` | - | `FunctionToolCallOutput` | The output from the function call generated by your code. Can be a string or an list of output content. | -| `status` | 是 | `string` | `in_progress`, `completed`, `incomplete` | `FunctionToolCallOutput` | The status of the item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 是 | `string` | `function_call_output` | `FunctionToolCallOutput` | The type of the function tool call output. Always function_call_output. | - -### `FunctionToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `defer_loading` | 否 | `boolean` | - | Whether this function should be deferred and discovered via tool search. | -| `description` | 否 | `string \| null` | - | - | -| `name` | 是 | `string` | - | - | -| `parameters` | 否 | `EmptyModelParam \| null` | - | - | -| `strict` | 否 | `boolean \| null` | - | - | -| `type` | 是 | `string` | `function` | - | - -### `GrammarSyntax1` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `HybridSearchOptions` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `embedding_weight` | 是 | `number` | - | The weight of the embedding in the reciprocal ranking fusion. | -| `text_weight` | 是 | `number` | - | The weight of the text in the reciprocal ranking fusion. | - -### `Image` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents the content or the URL of an image generated by the OpenAI API. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `b64_json` | 否 | `string` | - | The base64-encoded JSON of the generated image. Returned by default for the GPT image models, and only present if response_format is set to b64_json for dall-e-2 and dall-e-3. | -| `revised_prompt` | 否 | `string` | - | For dall-e-3 only, the revised prompt that was used to generate the image. | -| `url` | 否 | `string(uri)` | - | When using dall-e-2 or dall-e-3, the URL of the generated image if response_format is set to url (default value). Unsupported for the GPT image models. | - -### `ImageDetail` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ImageEditCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when image editing has completed and the final image is available. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `b64_json` | 是 | `string` | - | Base64-encoded final edited image data, suitable for rendering as an image. | -| `background` | 是 | `string` | `transparent`, `opaque`, `auto` | The background setting for the edited image. | -| `created_at` | 是 | `integer(unixtime)` | - | The Unix timestamp when the event was created. | -| `output_format` | 是 | `string` | `png`, `webp`, `jpeg` | The output format for the edited image. | -| `quality` | 是 | `string` | `low`, `medium`, `high`, `auto` | The quality setting for the edited image. | -| `size` | 是 | `string` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | The size of the edited image. | -| `type` | 是 | `string` | `image_edit.completed` | The type of the event. Always image_edit.completed. | -| `usage` | 是 | `ImagesUsage` | - | - | - -### `ImageEditPartialImageEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a partial image is available during image editing streaming. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `b64_json` | 是 | `string` | - | Base64-encoded partial image data, suitable for rendering as an image. | -| `background` | 是 | `string` | `transparent`, `opaque`, `auto` | The background setting for the requested edited image. | -| `created_at` | 是 | `integer(unixtime)` | - | The Unix timestamp when the event was created. | -| `output_format` | 是 | `string` | `png`, `webp`, `jpeg` | The output format for the requested edited image. | -| `partial_image_index` | 是 | `integer` | - | 0-based index for the partial image (streaming). | -| `quality` | 是 | `string` | `low`, `medium`, `high`, `auto` | The quality setting for the requested edited image. | -| `size` | 是 | `string` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | The size of the requested edited image. | -| `type` | 是 | `string` | `image_edit.partial_image` | The type of the event. Always image_edit.partial_image. | - -### `ImageEditStreamEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `ImageEditPartialImageEvent \| ImageEditCompletedEvent` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ImageEditPartialImageEvent` | - | -| 2 | `ImageEditCompletedEvent` | - | - -### `ImageGenActionEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ImageGenCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when image generation has completed and the final image is available. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `b64_json` | 是 | `string` | - | Base64-encoded image data, suitable for rendering as an image. | -| `background` | 是 | `string` | `transparent`, `opaque`, `auto` | The background setting for the generated image. | -| `created_at` | 是 | `integer(unixtime)` | - | The Unix timestamp when the event was created. | -| `output_format` | 是 | `string` | `png`, `webp`, `jpeg` | The output format for the generated image. | -| `quality` | 是 | `string` | `low`, `medium`, `high`, `auto` | The quality setting for the generated image. | -| `size` | 是 | `string` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | The size of the generated image. | -| `type` | 是 | `string` | `image_generation.completed` | The type of the event. Always image_generation.completed. | -| `usage` | 是 | `ImagesUsage` | - | - | - -### `ImageGenInputUsageDetails` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The input tokens detailed information for the image generation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `image_tokens` | 是 | `integer` | - | The number of image tokens in the input prompt. | -| `text_tokens` | 是 | `integer` | - | The number of text tokens in the input prompt. | - -### `ImageGenOutputTokensDetails` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output token details for the image generation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `image_tokens` | 是 | `integer` | - | The number of image output tokens generated by the model. | -| `text_tokens` | 是 | `integer` | - | The number of text output tokens generated by the model. | - -### `ImageGenPartialImageEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a partial image is available during image generation streaming. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `b64_json` | 是 | `string` | - | Base64-encoded partial image data, suitable for rendering as an image. | -| `background` | 是 | `string` | `transparent`, `opaque`, `auto` | The background setting for the requested image. | -| `created_at` | 是 | `integer(unixtime)` | - | The Unix timestamp when the event was created. | -| `output_format` | 是 | `string` | `png`, `webp`, `jpeg` | The output format for the requested image. | -| `partial_image_index` | 是 | `integer` | - | 0-based index for the partial image (streaming). | -| `quality` | 是 | `string` | `low`, `medium`, `high`, `auto` | The quality setting for the requested image. | -| `size` | 是 | `string` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | The size of the requested image. | -| `type` | 是 | `string` | `image_generation.partial_image` | The type of the event. Always image_generation.partial_image. | - -### `ImageGenStreamEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `ImageGenPartialImageEvent \| ImageGenCompletedEvent` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ImageGenPartialImageEvent` | - | -| 2 | `ImageGenCompletedEvent` | - | - -### `ImageGenTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that generates images using the GPT image models. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `action` | 否 | `ImageGenActionEnum` | - | Whether to generate a new image or edit an existing image. Default: auto. | -| `background` | 否 | `string` | `transparent`, `opaque`, `auto` | Background type for the generated image. One of transparent, opaque, or auto. Default: auto. | -| `input_fidelity` | 否 | `InputFidelity \| null` | - | - | -| `input_image_mask` | 否 | `object` | - | Optional mask for inpainting. Contains image_url (string, optional) and file_id (string, optional). | -| `model` | 否 | `string \| string` | - | - | -| `moderation` | 否 | `string` | `auto`, `low` | Moderation level for the generated image. Default: auto. | -| `output_compression` | 否 | `integer` | - | Compression level for the output image. Default: 100. | -| `output_format` | 否 | `string` | `png`, `webp`, `jpeg` | The output format of the generated image. One of png, webp, or jpeg. Default: png. | -| `partial_images` | 否 | `integer` | - | Number of partial images to generate in streaming mode, from 0 (default value) to 3. | -| `quality` | 否 | `string` | `low`, `medium`, `high`, `auto` | The quality of the generated image. One of low, medium, high, or auto. Default: auto. | -| `size` | 否 | `string \| string` | - | The size of the generated images. For gpt-image-2 and gpt-image-2-2026-04-21, arbitrary resolutions are supported as WIDTHxHEIGHT strings, for example 1536x864. Width and height m… | -| `type` | 是 | `string` | `image_generation` | The type of the image generation tool. Always image_generation. | - -### `ImageGenToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An image generation request made by the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The unique ID of the image generation call. | -| `result` | 是 | `string \| null` | - | - | -| `status` | 是 | `string` | `in_progress`, `completed`, `generating`, `failed` | The status of the image generation call. | -| `type` | 是 | `string` | `image_generation_call` | The type of the image generation call. Always image_generation_call. | - -### `ImageGenUsage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | For gpt-image-1 only, the token usage information for the image generation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `input_tokens` | 是 | `integer` | - | The number of tokens (images and text) in the input prompt. | -| `input_tokens_details` | 是 | `ImageGenInputUsageDetails` | - | - | -| `output_tokens` | 是 | `integer` | - | The number of output tokens generated by the model. | -| `output_tokens_details` | 否 | `ImageGenOutputTokensDetails` | - | - | -| `total_tokens` | 是 | `integer` | - | The total number of tokens (images and text) used for the image generation. | - -### `ImageRefParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object/value \| object/value` | -| 说明 | Reference an input image by either URL or uploaded file ID. Provide exactly one of image_url or file_id. | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object/value` | - | -| 2 | `object/value` | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file_id` | 否 | `string` | - | The File API ID of an uploaded image to use as input. | -| `image_url` | 否 | `string(uri)` | - | A fully qualified URL or base64-encoded data URL. | - -### `ImagesResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The response from the image generation endpoint. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `background` | 否 | `string` | `transparent`, `opaque` | The background parameter used for the image generation. Either transparent or opaque. | -| `created` | 是 | `integer(unixtime)` | - | The Unix timestamp (in seconds) of when the image was created. | -| `data` | 否 | `array` | - | The list of generated images. | -| `output_format` | 否 | `string` | `png`, `webp`, `jpeg` | The output format of the image generation. Either png, webp, or jpeg. | -| `quality` | 否 | `string` | `low`, `medium`, `high` | The quality of the image generated. Either low, medium, or high. | -| `size` | 否 | `string` | `1024x1024`, `1024x1536`, `1536x1024` | The size of the image generated. Either 1024x1024, 1024x1536, or 1536x1024. | -| `usage` | 否 | `ImageGenUsage` | - | - | - -### `ImagesUsage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | For the GPT image models only, the token usage information for the image generation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `input_tokens` | 是 | `integer` | - | The number of tokens (images and text) in the input prompt. | -| `input_tokens_details` | 是 | `object` | - | The input tokens detailed information for the image generation. | -| `output_tokens` | 是 | `integer` | - | The number of image tokens in the output image. | -| `total_tokens` | 是 | `integer` | - | The total number of tokens (images and text) used for the image generation. | - -### `IncludeEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Specify additional output data to include in the model response. Currently supported values are: - web_search_call.results: Include the search results of the web search tool call.… | - -### `InlineSkillParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 是 | `string` | - | The description of the skill. | -| `name` | 是 | `string` | - | The name of the skill. | -| `source` | 是 | `InlineSkillSourceParam` | - | Inline skill payload | -| `type` | 是 | `string` | `inline` | Defines an inline skill for this request. | - -### `InlineSkillSourceParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Inline skill payload | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `data` | 是 | `string` | - | Base64-encoded skill zip bundle. | -| `media_type` | 是 | `string` | `application/zip` | The media type of the inline skill payload. Must be application/zip. | -| `type` | 是 | `string` | `base64` | The type of the inline skill source. Must be base64. | - -### `InputContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `InputTextContent \| InputImageContent \| InputFileContent` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `InputTextContent` | - | -| 2 | `InputImageContent` | - | -| 3 | `InputFileContent` | - | - -### `InputFidelity` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Control how much effort the model will exert to match the style and features, especially facial features, of input images. This parameter is only supported for gpt-image-1 and gpt… | - -### `InputFileContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A file input to the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `detail` | 否 | `FileInputDetail` | - | The detail level of the file to be sent to the model. Use low for the default rendering behavior, or high to render the file at higher quality. Defaults to low. | -| `file_data` | 否 | `string` | - | The content of the file to be sent to the model. | -| `file_id` | 否 | `string \| null` | - | - | -| `file_url` | 否 | `string(uri)` | - | The URL of the file to be sent to the model. | -| `filename` | 否 | `string` | - | The name of the file to be sent to the model. | -| `type` | 是 | `string` | `input_file` | The type of the input item. Always input_file. | - -### `InputFileContentParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A file input to the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `detail` | 否 | `FileDetailEnum` | - | The detail level of the file to be sent to the model. Use low for the default rendering behavior, or high to render the file at higher quality. Defaults to low. | -| `file_data` | 否 | `string \| null` | - | - | -| `file_id` | 否 | `string \| null` | - | - | -| `file_url` | 否 | `string(uri) \| null` | - | - | -| `filename` | 否 | `string \| null` | - | - | -| `type` | 是 | `string` | `input_file` | The type of the input item. Always input_file. | - -### `InputImageContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An image input to the model. Learn about [image inputs](/docs/guides/vision). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `detail` | 是 | `ImageDetail` | - | The detail level of the image to be sent to the model. One of high, low, auto, or original. Defaults to auto. | -| `file_id` | 否 | `string \| null` | - | - | -| `image_url` | 否 | `string(uri) \| null` | - | - | -| `type` | 是 | `string` | `input_image` | The type of the input item. Always input_image. | - -### `InputImageContentParamAutoParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An image input to the model. Learn about [image inputs](/docs/guides/vision) | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `detail` | 否 | `DetailEnum \| null` | - | - | -| `file_id` | 否 | `string \| null` | - | - | -| `image_url` | 否 | `string(uri) \| null` | - | - | -| `type` | 是 | `string` | `input_image` | The type of the input item. Always input_image. | - -### `InputItem` - -| 项 | 值 | -| --- | --- | -| 类型 | `EasyInputMessage \| Item \| CompactionTriggerItemParam \| ItemReferenceParam` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `EasyInputMessage` | - | -| 2 | `Item` | An item representing part of the context for the response to be generated by the model. Can contain text, images, and audio inputs, as well as previous assistant responses and too… | -| 3 | `CompactionTriggerItemParam` | - | -| 4 | `ItemReferenceParam` | - | - -### `InputMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A message input to the model with a role indicating instruction following hierarchy. Instructions given with the developer or system role take precedence over instructions given w… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `InputMessageContentList` | - | - | -| `role` | 是 | `string` | `user`, `system`, `developer` | The role of the message input. One of user, system, or developer. | -| `status` | 否 | `string` | `in_progress`, `completed`, `incomplete` | The status of item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 否 | `string` | `message` | The type of the message input. Always set to message. | - -### `InputMessageContentList` - -| 项 | 值 | -| --- | --- | -| 类型 | `array` | -| 说明 | A list of one or many input items to the model, containing different content types. | - -### `InputParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| array` | -| 说明 | Text, image, or file inputs to the model, used to generate a response. Learn more: - [Text inputs and outputs](/docs/guides/text) - [Image inputs](/docs/guides/images) - [File inp… | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | A text input to the model, equivalent to a text input with the user role. | -| 2 | `array` | A list of one or many input items to the model, containing different content types. | - -### `InputTextContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A text input to the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | The text input to the model. | -| `type` | 是 | `string` | `input_text` | The type of the input item. Always input_text. | - -### `InputTextContentParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A text input to the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | The text input to the model. | -| `type` | 是 | `string` | `input_text` | The type of the input item. Always input_text. | - -### `Item` - -| 项 | 值 | -| --- | --- | -| 类型 | `InputMessage \| OutputMessage \| FileSearchToolCall \| ComputerToolCall \| ComputerCallOutputItemParam \| WebSearchToolCall \| FunctionToolCall \| FunctionCallOutputItemParam … (+19)` | -| 说明 | Content item used to generate a response. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `InputMessage` | - | -| 2 | `OutputMessage` | - | -| 3 | `FileSearchToolCall` | - | -| 4 | `ComputerToolCall` | - | -| 5 | `ComputerCallOutputItemParam` | - | -| 6 | `WebSearchToolCall` | - | -| 7 | `FunctionToolCall` | - | -| 8 | `FunctionCallOutputItemParam` | - | -| 9 | `ToolSearchCallItemParam` | - | -| 10 | `ToolSearchOutputItemParam` | - | -| 11 | `AdditionalToolsItemParam` | - | -| 12 | `ReasoningItem` | - | -| 13 | `CompactionSummaryItemParam` | - | -| 14 | `ImageGenToolCall` | - | -| 15 | `CodeInterpreterToolCall` | - | -| 16 | `LocalShellToolCall` | - | -| 17 | `LocalShellToolCallOutput` | - | -| 18 | `FunctionShellCallItemParam` | - | -| 19 | `FunctionShellCallOutputItemParam` | - | -| 20 | `ApplyPatchToolCallItemParam` | - | -| 21 | `ApplyPatchToolCallOutputItemParam` | - | -| 22 | `MCPListTools` | - | -| 23 | `MCPApprovalRequest` | - | -| 24 | `MCPApprovalResponse` | - | -| 25 | `MCPToolCall` | - | -| 26 | `CustomToolCallOutput` | - | -| 27 | `CustomToolCall` | - | - -### `ItemField` - -| 项 | 值 | -| --- | --- | -| 类型 | `Message \| FunctionToolCall \| ToolSearchCall \| ToolSearchOutput \| AdditionalTools \| FunctionToolCallOutput \| FileSearchToolCall \| WebSearchToolCall … (+18)` | -| 说明 | An item representing a message, tool call, tool output, reasoning, or other response element. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `Message` | - | -| 2 | `FunctionToolCall` | - | -| 3 | `ToolSearchCall` | - | -| 4 | `ToolSearchOutput` | - | -| 5 | `AdditionalTools` | - | -| 6 | `FunctionToolCallOutput` | - | -| 7 | `FileSearchToolCall` | - | -| 8 | `WebSearchToolCall` | - | -| 9 | `ImageGenToolCall` | - | -| 10 | `ComputerToolCall` | - | -| 11 | `ComputerToolCallOutputResource` | - | -| 12 | `ReasoningItem` | - | -| 13 | `CompactionBody` | - | -| 14 | `CodeInterpreterToolCall` | - | -| 15 | `LocalShellToolCall` | - | -| 16 | `LocalShellToolCallOutput` | - | -| 17 | `FunctionShellCall` | - | -| 18 | `FunctionShellCallOutput` | - | -| 19 | `ApplyPatchToolCall` | - | -| 20 | `ApplyPatchToolCallOutput` | - | -| 21 | `MCPListTools` | - | -| 22 | `MCPApprovalRequest` | - | -| 23 | `MCPApprovalResponseResource` | - | -| 24 | `MCPToolCall` | - | -| 25 | `CustomToolCall` | - | -| 26 | `CustomToolCallOutput` | - | - -### `ItemReferenceParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An internal identifier for an item to reference. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The ID of the item to reference. | -| `type` | 否 | `string \| null` | - | - | - -### `KeyPressAction` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A collection of keypresses the model would like to perform. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `keys` | 是 | `array` | - | The combination of keys the model is requesting to be pressed. This is an array of strings, each representing a key. | -| `type` | 是 | `string` | `keypress` | Specifies the event type. For a keypress action, this property is always set to keypress. | - -### `LocalEnvironmentParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `skills` | 否 | `array` | - | An optional list of skills. | -| `type` | 是 | `string` | `local` | Use a local computer environment. | - -### `LocalEnvironmentResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents the use of a local environment to perform shell actions. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `local` | The environment type. Always local. | - -### `LocalShellExecAction` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Execute a shell command on the server. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `command` | 是 | `array` | - | The command to run. | -| `env` | 是 | `object/map` | - | Environment variables to set for the command. | -| `timeout_ms` | 否 | `integer \| null` | - | - | -| `type` | 是 | `string` | `exec` | The type of the local shell action. Always exec. | -| `user` | 否 | `string \| null` | - | - | -| `working_directory` | 否 | `string \| null` | - | - | - -### `LocalShellToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool call to run a command on the local shell. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `action` | 是 | `LocalShellExecAction` | - | - | -| `call_id` | 是 | `string` | - | The unique ID of the local shell tool call generated by the model. | -| `id` | 是 | `string` | - | The unique ID of the local shell call. | -| `status` | 是 | `string` | `in_progress`, `completed`, `incomplete` | The status of the local shell call. | -| `type` | 是 | `string` | `local_shell_call` | The type of the local shell call. Always local_shell_call. | - -### `LocalShellToolCallOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output of a local shell tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 是 | `string` | - | The unique ID of the local shell tool call generated by the model. | -| `output` | 是 | `string` | - | A JSON string of the output of the local shell tool call. | -| `status` | 否 | `string \| null` | - | - | -| `type` | 是 | `string` | `local_shell_call_output` | The type of the local shell tool call output. Always local_shell_call_output. | - -### `LocalShellToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool that allows the model to execute shell commands in a local environment. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `local_shell` | The type of the local shell tool. Always local_shell. | - -### `LocalSkillParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 是 | `string` | - | The description of the skill. | -| `name` | 是 | `string` | - | The name of the skill. | -| `path` | 是 | `string` | - | The path to the directory containing the skill. | - -### `LogProb` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The log probability of a token. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `bytes` | 是 | `array` | - | - | -| `logprob` | 是 | `number` | - | - | -| `token` | 是 | `string` | - | - | -| `top_logprobs` | 是 | `array` | - | - | - -### `MCPApprovalRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A request for human approval of a tool invocation. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `arguments` | 是 | `string` | - | A JSON string of arguments for the tool. | -| `id` | 是 | `string` | - | The unique ID of the approval request. | -| `name` | 是 | `string` | - | The name of the tool to run. | -| `server_label` | 是 | `string` | - | The label of the MCP server making the request. | -| `type` | 是 | `string` | `mcp_approval_request` | The type of the item. Always mcp_approval_request. | - -### `MCPApprovalResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A response to an MCP approval request. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `approval_request_id` | 是 | `string` | - | The ID of the approval request being answered. | -| `approve` | 是 | `boolean` | - | Whether the request was approved. | -| `id` | 否 | `string \| null` | - | - | -| `reason` | 否 | `string \| null` | - | - | -| `type` | 是 | `string` | `mcp_approval_response` | The type of the item. Always mcp_approval_response. | - -### `MCPApprovalResponseResource` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A response to an MCP approval request. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `approval_request_id` | 是 | `string` | - | The ID of the approval request being answered. | -| `approve` | 是 | `boolean` | - | Whether the request was approved. | -| `id` | 是 | `string` | - | The unique ID of the approval response | -| `reason` | 否 | `string \| null` | - | - | -| `type` | 是 | `string` | `mcp_approval_response` | The type of the item. Always mcp_approval_response. | - -### `MCPListTools` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A list of tools available on an MCP server. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `error` | 否 | `string \| null` | - | - | -| `id` | 是 | `string` | - | The unique ID of the list. | -| `server_label` | 是 | `string` | - | The label of the MCP server. | -| `tools` | 是 | `array` | - | The tools available on the server. | -| `type` | 是 | `string` | `mcp_list_tools` | The type of the item. Always mcp_list_tools. | - -### `MCPListToolsTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A tool available on an MCP server. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `annotations` | 否 | `object \| null` | - | - | -| `description` | 否 | `string \| null` | - | - | -| `input_schema` | 是 | `object` | - | The JSON schema describing the tool's input. | -| `name` | 是 | `string` | - | The name of the tool. | - -### `MCPTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Give the model access to additional tools via remote Model Context Protocol (MCP) servers. [Learn more about MCP](/docs/guides/tools-remote-mcp). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `allowed_tools` | 否 | `array \| MCPToolFilter \| null` | - | - | -| `authorization` | 否 | `string` | - | An OAuth access token that can be used with a remote MCP server, either with a custom MCP server URL or a service connector. Your application must handle the OAuth authorization f… | -| `connector_id` | 否 | `string` | `connector_dropbox`, `connector_gmail`, `connector_googlecalendar`, `connector_googledrive`, `connector_microsoftteams`, `connector_outlookcalendar`, `connector_outlookemail`, `connector_sharepoint` | Identifier for service connectors, like those available in ChatGPT. One of server_url or connector_id must be provided. Learn more about service connectors [here](/docs/guides/too… | -| `defer_loading` | 否 | `boolean` | - | Whether this MCP tool is deferred and discovered via tool search. | -| `headers` | 否 | `object/map \| null` | - | - | -| `require_approval` | 否 | `object \| string \| null` | - | - | -| `server_description` | 否 | `string` | - | Optional description of the MCP server, used to provide more context. | -| `server_label` | 是 | `string` | - | A label for this MCP server, used to identify it in tool calls. | -| `server_url` | 否 | `string(uri)` | - | The URL for the MCP server. One of server_url or connector_id must be provided. | -| `type` | 是 | `string` | `mcp` | The type of the MCP tool. Always mcp. | - -### `MCPToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An invocation of a tool on an MCP server. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `approval_request_id` | 否 | `string \| null` | - | - | -| `arguments` | 是 | `string` | - | A JSON string of the arguments passed to the tool. | -| `error` | 否 | `string \| null` | - | - | -| `id` | 是 | `string` | - | The unique ID of the tool call. | -| `name` | 是 | `string` | - | The name of the tool that was run. | -| `output` | 否 | `string \| null` | - | - | -| `server_label` | 是 | `string` | - | The label of the MCP server running the tool. | -| `status` | 否 | `MCPToolCallStatus` | - | The status of the tool call. One of in_progress, completed, incomplete, calling, or failed. | -| `type` | 是 | `string` | `mcp_call` | The type of the item. Always mcp_call. | - -### `MCPToolCallStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `MCPToolFilter` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A filter object to specify which tools are allowed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `read_only` | 否 | `boolean` | - | Indicates whether or not a tool modifies data or is read-only. If an MCP server is [annotated with readOnlyHint](https://modelcontextprotocol.io/specification/2025-06-18/schema#to… | -| `tool_names` | 否 | `array` | - | List of allowed tool names. | - -### `Message` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A message to or from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `array` | - | The content of the message | -| `id` | 是 | `string` | - | The unique ID of the message. | -| `phase` | 否 | `MessagePhase-2 \| null` | - | - | -| `role` | 是 | `MessageRole` | - | The role of the message. One of unknown, user, assistant, system, critic, discriminator, developer, or tool. | -| `status` | 是 | `MessageStatus` | - | The status of item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `type` | 是 | `string` | `message` | The type of the message. Always set to message. | - -### `MessagePhase` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Labels an assistant message as intermediate commentary (commentary) or the final answer (final_answer). For models like gpt-5.3-codex and beyond, when sending follow-up requests, … | - -### `MessagePhase-2` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `MessageRole` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `MessageStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `Metadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object/map \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object/map` | Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format, and querying for object… | -| 2 | `null` | - | - -### `ModelIdsCompaction` - -| 项 | 值 | -| --- | --- | -| 类型 | `ModelIdsResponses \| string \| null` | -| 说明 | Model ID used to generate the response, like gpt-5 or o3. OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points. Refer to… | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ModelIdsResponses` | - | -| 2 | `string` | - | -| 3 | `null` | - | - -### `ModelIdsResponses` - -| 项 | 值 | -| --- | --- | -| 类型 | `ModelIdsShared \| string` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ModelIdsShared` | - | -| 2 | `string` | - | - -### `ModelIdsShared` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| string` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | - | -| 2 | `string` | - | - -### `ModelResponseProperties` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `metadata` | 否 | `Metadata` | - | - | -| `prompt_cache_key` | 否 | `string` | - | Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field. [Learn more](/docs/guides/prompt-caching). | -| `prompt_cache_retention` | 否 | `string \| null` | - | - | -| `safety_identifier` | 否 | `string` | - | A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user, wit… | -| `service_tier` | 否 | `ServiceTier` | - | - | -| `temperature` | 否 | `number \| null` | - | - | -| `top_logprobs` | 否 | `integer \| null` | - | - | -| `top_p` | 否 | `number \| null` | - | - | -| `user` | 否 | `string` | - | This field is being replaced by safety_identifier and prompt_cache_key. Use prompt_cache_key instead to maintain caching optimizations. A stable identifier for your end-users. Use… | - -### `MoveParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A mouse move action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `keys` | 否 | `array \| null` | - | - | -| `type` | 是 | `string` | `move` | Specifies the event type. For a move action, this property is always set to move. | -| `x` | 是 | `integer` | - | The x-coordinate to move to. | -| `y` | 是 | `integer` | - | The y-coordinate to move to. | - -### `NamespaceToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Groups function/custom tools under a shared namespace. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 是 | `string` | - | A description of the namespace shown to the model. | -| `name` | 是 | `string` | - | The namespace name used in tool calls (for example, crm). | -| `tools` | 是 | `array` | - | The function/custom tools available inside this namespace. | -| `type` | 是 | `string` | `namespace` | The type of the tool. Always namespace. | - -### `OutputContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `OutputTextContent \| RefusalContent \| ReasoningTextContent` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `OutputTextContent` | - | -| 2 | `RefusalContent` | - | -| 3 | `ReasoningTextContent` | - | - -### `OutputItem` - -| 项 | 值 | -| --- | --- | -| 类型 | `OutputMessage \| FileSearchToolCall \| FunctionToolCall \| FunctionToolCallOutputResource \| WebSearchToolCall \| ComputerToolCall \| ComputerToolCallOutputResource \| ReasoningItem … (+18)` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `OutputMessage` | - | -| 2 | `FileSearchToolCall` | - | -| 3 | `FunctionToolCall` | - | -| 4 | `FunctionToolCallOutputResource` | - | -| 5 | `WebSearchToolCall` | - | -| 6 | `ComputerToolCall` | - | -| 7 | `ComputerToolCallOutputResource` | - | -| 8 | `ReasoningItem` | - | -| 9 | `ToolSearchCall` | - | -| 10 | `ToolSearchOutput` | - | -| 11 | `AdditionalTools` | - | -| 12 | `CompactionBody` | - | -| 13 | `ImageGenToolCall` | - | -| 14 | `CodeInterpreterToolCall` | - | -| 15 | `LocalShellToolCall` | - | -| 16 | `LocalShellToolCallOutput` | - | -| 17 | `FunctionShellCall` | - | -| 18 | `FunctionShellCallOutput` | - | -| 19 | `ApplyPatchToolCall` | - | -| 20 | `ApplyPatchToolCallOutput` | - | -| 21 | `MCPToolCall` | - | -| 22 | `MCPListTools` | - | -| 23 | `MCPApprovalRequest` | - | -| 24 | `MCPApprovalResponseResource` | - | -| 25 | `CustomToolCall` | - | -| 26 | `CustomToolCallOutputResource` | - | - -### `OutputMessage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An output message from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `array` | - | The content of the output message. | -| `id` | 是 | `string` | - | The unique ID of the output message. | -| `phase` | 否 | `MessagePhase \| null` | - | - | -| `role` | 是 | `string` | `assistant` | The role of the output message. Always assistant. | -| `status` | 是 | `string` | `in_progress`, `completed`, `incomplete` | The status of the message input. One of in_progress, completed, or incomplete. Populated when input items are returned via API. | -| `type` | 是 | `string` | `message` | The type of the output message. Always message. | - -### `OutputMessageContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `OutputTextContent \| RefusalContent` | -| 说明 | - | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `OutputTextContent` | - | -| 2 | `RefusalContent` | - | - -### `OutputTextContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A text output from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `annotations` | 是 | `array` | - | The annotations of the text output. | -| `logprobs` | 是 | `array` | - | - | -| `text` | 是 | `string` | - | The text output from the model. | -| `type` | 是 | `string` | `output_text` | The type of the output text. Always output_text. | - -### `ParallelToolCalls` - -| 项 | 值 | -| --- | --- | -| 类型 | `boolean` | -| 说明 | Whether to enable [parallel function calling](/docs/guides/function-calling#configuring-parallel-function-calling) during tool use. | - -### `PartialImages` - -| 项 | 值 | -| --- | --- | -| 类型 | `integer \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `integer` | The number of partial images to generate. This parameter is used for streaming responses that return partial images. Value must be between 0 and 3. When set to 0, the response wil… | -| 2 | `null` | - | - -### `PredictionContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Static predicted output content, such as the content of a text file that is being regenerated. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 是 | `string \| array` | - | The content that should be matched when generating a model response. If generated tokens would match this content, the entire model response can be returned much more quickly. | -| `type` | 是 | `string` | `content` | The type of the predicted content you want to provide. This type is currently always content. | - -### `Prompt` - -| 项 | 值 | -| --- | --- | -| 类型 | `object \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object` | Reference to a prompt template and its variables. [Learn more](/docs/guides/text?api-mode=responses#reusable-prompts). | -| 2 | `null` | - | - -### `PromptCacheRetentionEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `RankerVersionType` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `RankingOptions` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `hybrid_search` | 否 | `HybridSearchOptions` | - | Weights that control how reciprocal rank fusion balances semantic embedding matches versus sparse keyword matches when hybrid search is enabled. | -| `ranker` | 否 | `RankerVersionType` | - | The ranker to use for the file search. | -| `score_threshold` | 否 | `number` | - | The score threshold for the file search, a number between 0 and 1. Numbers closer to 1 will attempt to return only the most relevant results, but may return fewer results. | - -### `Reasoning` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | **gpt-5 and o-series models only** Configuration options for [reasoning models](https://platform.openai.com/docs/guides/reasoning). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `effort` | 否 | `ReasoningEffort` | - | - | -| `generate_summary` | 否 | `string \| null` | - | - | -| `summary` | 否 | `string \| null` | - | - | - -### `ReasoningEffort` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | Constrains effort on reasoning for [reasoning models](https://platform.openai.com/docs/guides/reasoning). Currently supported values are none, minimal, low, medium, high, and xhig… | -| 2 | `null` | - | - -### `ReasoningItem` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A description of the chain of thought used by a reasoning model while generating a response. Be sure to include these items in your input to the Responses API for subsequent turns… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 否 | `array` | - | Reasoning text content. | -| `encrypted_content` | 否 | `string \| null` | - | - | -| `id` | 是 | `string` | - | The unique identifier of the reasoning content. | -| `status` | 否 | `string` | `in_progress`, `completed`, `incomplete` | The status of the item. One of in_progress, completed, or incomplete. Populated when items are returned via API. | -| `summary` | 是 | `array` | - | Reasoning summary content. | -| `type` | 是 | `string` | `reasoning` | The type of the object. Always reasoning. | - -### `ReasoningTextContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Reasoning text from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | The reasoning text from the model. | -| `type` | 是 | `string` | `reasoning_text` | The type of the reasoning text. Always reasoning_text. | - -### `RefusalContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A refusal from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `refusal` | 是 | `string` | - | The refusal explanation from the model. | -| `type` | 是 | `string` | `refusal` | The type of the refusal. Always refusal. | - -### `Response` - -| 项 | 值 | -| --- | --- | -| 类型 | `ModelResponseProperties & ResponseProperties & object` | -| 说明 | - | -| 组合 | `allOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ModelResponseProperties` | - | -| 2 | `ResponseProperties` | - | -| 3 | `object` | - | - -#### allOf 展开字段 - -| 字段 | 必填 | 类型 | 枚举/常量 | 来源 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `background` | 否 | `boolean \| null` | - | `ResponseProperties` | - | -| `completed_at` | 否 | `number(unixtime) \| null` | - | `Response.allOf[3]` | - | -| `conversation` | 否 | `Conversation-2 \| null` | - | `Response.allOf[3]` | - | -| `created_at` | 是 | `number(unixtime)` | - | `Response.allOf[3]` | Unix timestamp (in seconds) of when this Response was created. | -| `error` | 是 | `ResponseError` | - | `Response.allOf[3]` | - | -| `id` | 是 | `string` | - | `Response.allOf[3]` | Unique identifier for this Response. | -| `incomplete_details` | 是 | `object \| null` | - | `Response.allOf[3]` | - | -| `instructions` | 是 | `string \| array \| null` | - | `Response.allOf[3]` | - | -| `max_output_tokens` | 否 | `integer \| null` | - | `Response.allOf[3]` | - | -| `max_tool_calls` | 否 | `integer \| null` | - | `ResponseProperties` | - | -| `metadata` | 是 | `Metadata` | - | `ModelResponseProperties` | - | -| `model` | 是 | `ModelIdsResponses` | - | `ResponseProperties` | Model ID used to generate the response, like gpt-4o or o3. OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points. Refer t… | -| `object` | 是 | `string` | `response` | `Response.allOf[3]` | The object type of this resource - always set to response. | -| `output` | 是 | `array` | - | `Response.allOf[3]` | An array of content items generated by the model. - The length and order of items in the output array is dependent on the model's response. - Rather than accessing the first item … | -| `output_text` | 否 | `string \| null` | - | `Response.allOf[3]` | - | -| `parallel_tool_calls` | 是 | `boolean` | - | `Response.allOf[3]` | Whether to allow the model to run tool calls in parallel. | -| `previous_response_id` | 否 | `string \| null` | - | `ResponseProperties` | - | -| `prompt` | 否 | `Prompt` | - | `ResponseProperties` | - | -| `prompt_cache_key` | 否 | `string` | - | `ModelResponseProperties` | Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field. [Learn more](/docs/guides/prompt-caching). | -| `prompt_cache_retention` | 否 | `string \| null` | - | `ModelResponseProperties` | - | -| `reasoning` | 否 | `Reasoning \| null` | - | `ResponseProperties` | - | -| `safety_identifier` | 否 | `string` | - | `ModelResponseProperties` | A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user, wit… | -| `service_tier` | 否 | `ServiceTier` | - | `ModelResponseProperties` | - | -| `status` | 否 | `string` | `completed`, `failed`, `in_progress`, `cancelled`, `queued`, `incomplete` | `Response.allOf[3]` | The status of the response generation. One of completed, failed, in_progress, cancelled, queued, or incomplete. | -| `temperature` | 是 | `number \| null` | - | `ModelResponseProperties` | - | -| `text` | 否 | `ResponseTextParam` | - | `ResponseProperties` | - | -| `tool_choice` | 是 | `ToolChoiceParam` | - | `ResponseProperties` | - | -| `tools` | 是 | `ToolsArray` | - | `ResponseProperties` | - | -| `top_logprobs` | 否 | `integer \| null` | - | `ModelResponseProperties` | - | -| `top_p` | 是 | `number \| null` | - | `ModelResponseProperties` | - | -| `truncation` | 否 | `string \| null` | - | `ResponseProperties` | - | -| `usage` | 否 | `ResponseUsage` | - | `Response.allOf[3]` | - | -| `user` | 否 | `string` | - | `ModelResponseProperties` | This field is being replaced by safety_identifier and prompt_cache_key. Use prompt_cache_key instead to maintain caching optimizations. A stable identifier for your end-users. Use… | - -### `ResponseAudioDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when there is a partial audio response. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | A chunk of Base64 encoded response audio bytes. | -| `sequence_number` | 是 | `integer` | - | A sequence number for this chunk of the stream response. | -| `type` | 是 | `string` | `response.audio.delta` | The type of the event. Always response.audio.delta. | - -### `ResponseAudioDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the audio response is complete. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `sequence_number` | 是 | `integer` | - | The sequence number of the delta. | -| `type` | 是 | `string` | `response.audio.done` | The type of the event. Always response.audio.done. | - -### `ResponseAudioTranscriptDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when there is a partial transcript of audio. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | The partial transcript of the audio response. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.audio.transcript.delta` | The type of the event. Always response.audio.transcript.delta. | - -### `ResponseAudioTranscriptDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the full audio transcript is completed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.audio.transcript.done` | The type of the event. Always response.audio.transcript.done. | - -### `ResponseCodeInterpreterCallCodeDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a partial code snippet is streamed by the code interpreter. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | The partial code snippet being streamed by the code interpreter. | -| `item_id` | 是 | `string` | - | The unique identifier of the code interpreter tool call item. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response for which the code is being streamed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event, used to order streaming events. | -| `type` | 是 | `string` | `response.code_interpreter_call_code.delta` | The type of the event. Always response.code_interpreter_call_code.delta. | - -### `ResponseCodeInterpreterCallCodeDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the code snippet is finalized by the code interpreter. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `code` | 是 | `string` | - | The final code snippet output by the code interpreter. | -| `item_id` | 是 | `string` | - | The unique identifier of the code interpreter tool call item. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response for which the code is finalized. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event, used to order streaming events. | -| `type` | 是 | `string` | `response.code_interpreter_call_code.done` | The type of the event. Always response.code_interpreter_call_code.done. | - -### `ResponseCodeInterpreterCallCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the code interpreter call is completed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the code interpreter tool call item. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response for which the code interpreter call is completed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event, used to order streaming events. | -| `type` | 是 | `string` | `response.code_interpreter_call.completed` | The type of the event. Always response.code_interpreter_call.completed. | - -### `ResponseCodeInterpreterCallInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a code interpreter call is in progress. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the code interpreter tool call item. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response for which the code interpreter call is in progress. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event, used to order streaming events. | -| `type` | 是 | `string` | `response.code_interpreter_call.in_progress` | The type of the event. Always response.code_interpreter_call.in_progress. | - -### `ResponseCodeInterpreterCallInterpretingEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the code interpreter is actively interpreting the code snippet. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the code interpreter tool call item. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response for which the code interpreter is interpreting code. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event, used to order streaming events. | -| `type` | 是 | `string` | `response.code_interpreter_call.interpreting` | The type of the event. Always response.code_interpreter_call.interpreting. | - -### `ResponseCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the model response is complete. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `response` | 是 | `Response` | - | Properties of the completed response. | -| `sequence_number` | 是 | `integer` | - | The sequence number for this event. | -| `type` | 是 | `string` | `response.completed` | The type of the event. Always response.completed. | - -### `ResponseContentPartAddedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a new content part is added. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the content part that was added. | -| `item_id` | 是 | `string` | - | The ID of the output item that the content part was added to. | -| `output_index` | 是 | `integer` | - | The index of the output item that the content part was added to. | -| `part` | 是 | `OutputContent` | - | The content part that was added. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.content_part.added` | The type of the event. Always response.content_part.added. | - -### `ResponseContentPartDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a content part is done. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the content part that is done. | -| `item_id` | 是 | `string` | - | The ID of the output item that the content part was added to. | -| `output_index` | 是 | `integer` | - | The index of the output item that the content part was added to. | -| `part` | 是 | `OutputContent` | - | The content part that is done. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.content_part.done` | The type of the event. Always response.content_part.done. | - -### `ResponseCreatedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An event that is emitted when a response is created. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `response` | 是 | `Response` | - | The response that was created. | -| `sequence_number` | 是 | `integer` | - | The sequence number for this event. | -| `type` | 是 | `string` | `response.created` | The type of the event. Always response.created. | - -### `ResponseCustomToolCallInputDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Event representing a delta (partial update) to the input of a custom tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | The incremental input data (delta) for the custom tool call. | -| `item_id` | 是 | `string` | - | Unique identifier for the API item associated with this event. | -| `output_index` | 是 | `integer` | - | The index of the output this delta applies to. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.custom_tool_call_input.delta` | The event type identifier. | - -### `ResponseCustomToolCallInputDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Event indicating that input for a custom tool call is complete. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `input` | 是 | `string` | - | The complete input data for the custom tool call. | -| `item_id` | 是 | `string` | - | Unique identifier for the API item associated with this event. | -| `output_index` | 是 | `integer` | - | The index of the output this event applies to. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.custom_tool_call_input.done` | The event type identifier. | - -### `ResponseError` - -| 项 | 值 | -| --- | --- | -| 类型 | `object \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object` | An error object returned when the model fails to generate a Response. | -| 2 | `null` | - | - -### `ResponseErrorCode` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | The error code for the response. | - -### `ResponseErrorEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an error occurs. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `code` | 是 | `string \| null` | - | - | -| `message` | 是 | `string` | - | The error message. | -| `param` | 是 | `string \| null` | - | - | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `error` | The type of the event. Always error. | - -### `ResponseFailedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An event that is emitted when a response fails. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `response` | 是 | `Response` | - | The response that failed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.failed` | The type of the event. Always response.failed. | - -### `ResponseFileSearchCallCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a file search call is completed (results found). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the output item that the file search call is initiated. | -| `output_index` | 是 | `integer` | - | The index of the output item that the file search call is initiated. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.file_search_call.completed` | The type of the event. Always response.file_search_call.completed. | - -### `ResponseFileSearchCallInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a file search call is initiated. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the output item that the file search call is initiated. | -| `output_index` | 是 | `integer` | - | The index of the output item that the file search call is initiated. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.file_search_call.in_progress` | The type of the event. Always response.file_search_call.in_progress. | - -### `ResponseFileSearchCallSearchingEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a file search is currently searching. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the output item that the file search call is initiated. | -| `output_index` | 是 | `integer` | - | The index of the output item that the file search call is searching. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.file_search_call.searching` | The type of the event. Always response.file_search_call.searching. | - -### `ResponseFormatJsonObject` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | JSON object response format. An older method of generating JSON responses. Using json_schema is recommended for models that support it. Note that the model will not generate JSON … | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `json_object` | The type of response format being defined. Always json_object. | - -### `ResponseFormatJsonSchema` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | JSON Schema response format. Used to generate structured JSON responses. Learn more about [Structured Outputs](/docs/guides/structured-outputs). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `json_schema` | 是 | `object` | - | Structured Outputs configuration options, including a JSON Schema. | -| `type` | 是 | `string` | `json_schema` | The type of response format being defined. Always json_schema. | - -### `ResponseFormatJsonSchemaSchema` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The schema for the response format, described as a JSON Schema object. Learn how to build JSON schemas [here](https://json-schema.org/). | - -Additional properties: `任意 JSON 值` - -### `ResponseFormatText` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Default response format. Used to generate text responses. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `text` | The type of response format being defined. Always text. | - -### `ResponseFunctionCallArgumentsDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when there is a partial function-call arguments delta. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | The function-call arguments delta that is added. | -| `item_id` | 是 | `string` | - | The ID of the output item that the function-call arguments delta is added to. | -| `output_index` | 是 | `integer` | - | The index of the output item that the function-call arguments delta is added to. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.function_call_arguments.delta` | The type of the event. Always response.function_call_arguments.delta. | - -### `ResponseFunctionCallArgumentsDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when function-call arguments are finalized. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `arguments` | 是 | `string` | - | The function-call arguments. | -| `item_id` | 是 | `string` | - | The ID of the item. | -| `name` | 是 | `string` | - | The name of the function that was called. | -| `output_index` | 是 | `integer` | - | The index of the output item. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.function_call_arguments.done` | - | - -### `ResponseImageGenCallCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an image generation tool call has completed and the final image is available. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the image generation item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.image_generation_call.completed` | The type of the event. Always 'response.image_generation_call.completed'. | - -### `ResponseImageGenCallGeneratingEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an image generation tool call is actively generating an image (intermediate state). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the image generation item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of the image generation item being processed. | -| `type` | 是 | `string` | `response.image_generation_call.generating` | The type of the event. Always 'response.image_generation_call.generating'. | - -### `ResponseImageGenCallInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an image generation tool call is in progress. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the image generation item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of the image generation item being processed. | -| `type` | 是 | `string` | `response.image_generation_call.in_progress` | The type of the event. Always 'response.image_generation_call.in_progress'. | - -### `ResponseImageGenCallPartialImageEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a partial image is available during image generation streaming. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the image generation item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `partial_image_b64` | 是 | `string` | - | Base64-encoded partial image data, suitable for rendering as an image. | -| `partial_image_index` | 是 | `integer` | - | 0-based index for the partial image (backend is 1-based, but this is 0-based for the user). | -| `sequence_number` | 是 | `integer` | - | The sequence number of the image generation item being processed. | -| `type` | 是 | `string` | `response.image_generation_call.partial_image` | The type of the event. Always 'response.image_generation_call.partial_image'. | - -### `ResponseInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the response is in progress. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `response` | 是 | `Response` | - | The response that is in progress. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.in_progress` | The type of the event. Always response.in_progress. | - -### `ResponseIncompleteEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An event that is emitted when a response finishes as incomplete. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `response` | 是 | `Response` | - | The response that was incomplete. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.incomplete` | The type of the event. Always response.incomplete. | - -### `ResponseLogProb` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A logprob is the logarithmic probability that the model assigns to producing a particular token at a given position in the sequence. Less-negative (higher) logprob values indicate… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `logprob` | 是 | `number` | - | The log probability of this token. | -| `token` | 是 | `string` | - | A possible text token. | -| `top_logprobs` | 否 | `array` | - | The log probabilities of up to 20 of the most likely tokens. | - -### `ResponseMCPCallArgumentsDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when there is a delta (partial update) to the arguments of an MCP tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | A JSON string containing the partial update to the arguments for the MCP tool call. | -| `item_id` | 是 | `string` | - | The unique identifier of the MCP tool call item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_call_arguments.delta` | The type of the event. Always 'response.mcp_call_arguments.delta'. | - -### `ResponseMCPCallArgumentsDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the arguments for an MCP tool call are finalized. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `arguments` | 是 | `string` | - | A JSON string containing the finalized arguments for the MCP tool call. | -| `item_id` | 是 | `string` | - | The unique identifier of the MCP tool call item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_call_arguments.done` | The type of the event. Always 'response.mcp_call_arguments.done'. | - -### `ResponseMCPCallCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an MCP tool call has completed successfully. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the MCP tool call item that completed. | -| `output_index` | 是 | `integer` | - | The index of the output item that completed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_call.completed` | The type of the event. Always 'response.mcp_call.completed'. | - -### `ResponseMCPCallFailedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an MCP tool call has failed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the MCP tool call item that failed. | -| `output_index` | 是 | `integer` | - | The index of the output item that failed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_call.failed` | The type of the event. Always 'response.mcp_call.failed'. | - -### `ResponseMCPCallInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an MCP tool call is in progress. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The unique identifier of the MCP tool call item being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_call.in_progress` | The type of the event. Always 'response.mcp_call.in_progress'. | - -### `ResponseMCPListToolsCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the list of available MCP tools has been successfully retrieved. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the MCP tool call item that produced this output. | -| `output_index` | 是 | `integer` | - | The index of the output item that was processed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_list_tools.completed` | The type of the event. Always 'response.mcp_list_tools.completed'. | - -### `ResponseMCPListToolsFailedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the attempt to list available MCP tools has failed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the MCP tool call item that failed. | -| `output_index` | 是 | `integer` | - | The index of the output item that failed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_list_tools.failed` | The type of the event. Always 'response.mcp_list_tools.failed'. | - -### `ResponseMCPListToolsInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when the system is in the process of retrieving the list of available MCP tools. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the MCP tool call item that is being processed. | -| `output_index` | 是 | `integer` | - | The index of the output item that is being processed. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.mcp_list_tools.in_progress` | The type of the event. Always 'response.mcp_list_tools.in_progress'. | - -### `ResponseModalities` - -| 项 | 值 | -| --- | --- | -| 类型 | `array \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `array` | Output types that you would like the model to generate. Most models are capable of generating text, which is the default: ["text"] The gpt-4o-audio-preview model can also be used … | -| 2 | `null` | - | - -### `ResponseOutputItemAddedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a new output item is added. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item` | 是 | `OutputItem` | - | The output item that was added. | -| `output_index` | 是 | `integer` | - | The index of the output item that was added. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.output_item.added` | The type of the event. Always response.output_item.added. | - -### `ResponseOutputItemDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an output item is marked done. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item` | 是 | `OutputItem` | - | The output item that was marked done. | -| `output_index` | 是 | `integer` | - | The index of the output item that was marked done. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.output_item.done` | The type of the event. Always response.output_item.done. | - -### `ResponseOutputTextAnnotationAddedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when an annotation is added to output text content. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `annotation` | 是 | `object` | - | The annotation object being added. (See annotation schema for details.) | -| `annotation_index` | 是 | `integer` | - | The index of the annotation within the content part. | -| `content_index` | 是 | `integer` | - | The index of the content part within the output item. | -| `item_id` | 是 | `string` | - | The unique identifier of the item to which the annotation is being added. | -| `output_index` | 是 | `integer` | - | The index of the output item in the response's output array. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.output_text.annotation.added` | The type of the event. Always 'response.output_text.annotation.added'. | - -### `ResponsePromptVariables` - -| 项 | 值 | -| --- | --- | -| 类型 | `object/map \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object/map` | Optional map of values to substitute in for variables in your prompt. The substitution values can either be strings, or other Response input types like images or files. | -| 2 | `null` | - | - -### `ResponseProperties` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `background` | 否 | `boolean \| null` | - | - | -| `max_tool_calls` | 否 | `integer \| null` | - | - | -| `model` | 否 | `ModelIdsResponses` | - | Model ID used to generate the response, like gpt-4o or o3. OpenAI offers a wide range of models with different capabilities, performance characteristics, and price points. Refer t… | -| `previous_response_id` | 否 | `string \| null` | - | - | -| `prompt` | 否 | `Prompt` | - | - | -| `reasoning` | 否 | `Reasoning \| null` | - | - | -| `text` | 否 | `ResponseTextParam` | - | - | -| `tool_choice` | 否 | `ToolChoiceParam` | - | - | -| `tools` | 否 | `ToolsArray` | - | - | -| `truncation` | 否 | `string \| null` | - | - | - -### `ResponseQueuedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a response is queued and waiting to be processed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `response` | 是 | `Response` | - | The full response object that is queued. | -| `sequence_number` | 是 | `integer` | - | The sequence number for this event. | -| `type` | 是 | `string` | `response.queued` | The type of the event. Always 'response.queued'. | - -### `ResponseReasoningSummaryPartAddedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a new reasoning summary part is added. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the item this summary part is associated with. | -| `output_index` | 是 | `integer` | - | The index of the output item this summary part is associated with. | -| `part` | 是 | `object` | - | The summary part that was added. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `summary_index` | 是 | `integer` | - | The index of the summary part within the reasoning summary. | -| `type` | 是 | `string` | `response.reasoning_summary_part.added` | The type of the event. Always response.reasoning_summary_part.added. | - -### `ResponseReasoningSummaryPartDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a reasoning summary part is completed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the item this summary part is associated with. | -| `output_index` | 是 | `integer` | - | The index of the output item this summary part is associated with. | -| `part` | 是 | `object` | - | The completed summary part. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `summary_index` | 是 | `integer` | - | The index of the summary part within the reasoning summary. | -| `type` | 是 | `string` | `response.reasoning_summary_part.done` | The type of the event. Always response.reasoning_summary_part.done. | - -### `ResponseReasoningSummaryTextDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a delta is added to a reasoning summary text. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `delta` | 是 | `string` | - | The text delta that was added to the summary. | -| `item_id` | 是 | `string` | - | The ID of the item this summary text delta is associated with. | -| `output_index` | 是 | `integer` | - | The index of the output item this summary text delta is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `summary_index` | 是 | `integer` | - | The index of the summary part within the reasoning summary. | -| `type` | 是 | `string` | `response.reasoning_summary_text.delta` | The type of the event. Always response.reasoning_summary_text.delta. | - -### `ResponseReasoningSummaryTextDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a reasoning summary text is completed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | The ID of the item this summary text is associated with. | -| `output_index` | 是 | `integer` | - | The index of the output item this summary text is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `summary_index` | 是 | `integer` | - | The index of the summary part within the reasoning summary. | -| `text` | 是 | `string` | - | The full text of the completed reasoning summary. | -| `type` | 是 | `string` | `response.reasoning_summary_text.done` | The type of the event. Always response.reasoning_summary_text.done. | - -### `ResponseReasoningTextDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a delta is added to a reasoning text. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the reasoning content part this delta is associated with. | -| `delta` | 是 | `string` | - | The text delta that was added to the reasoning content. | -| `item_id` | 是 | `string` | - | The ID of the item this reasoning text delta is associated with. | -| `output_index` | 是 | `integer` | - | The index of the output item this reasoning text delta is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.reasoning_text.delta` | The type of the event. Always response.reasoning_text.delta. | - -### `ResponseReasoningTextDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a reasoning text is completed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the reasoning content part. | -| `item_id` | 是 | `string` | - | The ID of the item this reasoning text is associated with. | -| `output_index` | 是 | `integer` | - | The index of the output item this reasoning text is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `text` | 是 | `string` | - | The full text of the completed reasoning content. | -| `type` | 是 | `string` | `response.reasoning_text.done` | The type of the event. Always response.reasoning_text.done. | - -### `ResponseRefusalDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when there is a partial refusal text. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the content part that the refusal text is added to. | -| `delta` | 是 | `string` | - | The refusal text that is added. | -| `item_id` | 是 | `string` | - | The ID of the output item that the refusal text is added to. | -| `output_index` | 是 | `integer` | - | The index of the output item that the refusal text is added to. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.refusal.delta` | The type of the event. Always response.refusal.delta. | - -### `ResponseRefusalDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when refusal text is finalized. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the content part that the refusal text is finalized. | -| `item_id` | 是 | `string` | - | The ID of the output item that the refusal text is finalized. | -| `output_index` | 是 | `integer` | - | The index of the output item that the refusal text is finalized. | -| `refusal` | 是 | `string` | - | The refusal text that is finalized. | -| `sequence_number` | 是 | `integer` | - | The sequence number of this event. | -| `type` | 是 | `string` | `response.refusal.done` | The type of the event. Always response.refusal.done. | - -### `ResponseStreamEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `ResponseAudioDeltaEvent \| ResponseAudioDoneEvent \| ResponseAudioTranscriptDeltaEvent \| ResponseAudioTranscriptDoneEvent \| ResponseCodeInterpreterCallCodeDeltaEvent \| ResponseCodeInterpreterCallCodeDoneEvent \| ResponseCodeInterpreterCallCompletedEvent \| ResponseCodeInterpreterCallInProgressEvent … (+45)` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ResponseAudioDeltaEvent` | - | -| 2 | `ResponseAudioDoneEvent` | - | -| 3 | `ResponseAudioTranscriptDeltaEvent` | - | -| 4 | `ResponseAudioTranscriptDoneEvent` | - | -| 5 | `ResponseCodeInterpreterCallCodeDeltaEvent` | - | -| 6 | `ResponseCodeInterpreterCallCodeDoneEvent` | - | -| 7 | `ResponseCodeInterpreterCallCompletedEvent` | - | -| 8 | `ResponseCodeInterpreterCallInProgressEvent` | - | -| 9 | `ResponseCodeInterpreterCallInterpretingEvent` | - | -| 10 | `ResponseCompletedEvent` | - | -| 11 | `ResponseContentPartAddedEvent` | - | -| 12 | `ResponseContentPartDoneEvent` | - | -| 13 | `ResponseCreatedEvent` | - | -| 14 | `ResponseErrorEvent` | - | -| 15 | `ResponseFileSearchCallCompletedEvent` | - | -| 16 | `ResponseFileSearchCallInProgressEvent` | - | -| 17 | `ResponseFileSearchCallSearchingEvent` | - | -| 18 | `ResponseFunctionCallArgumentsDeltaEvent` | - | -| 19 | `ResponseFunctionCallArgumentsDoneEvent` | - | -| 20 | `ResponseInProgressEvent` | - | -| 21 | `ResponseFailedEvent` | - | -| 22 | `ResponseIncompleteEvent` | - | -| 23 | `ResponseOutputItemAddedEvent` | - | -| 24 | `ResponseOutputItemDoneEvent` | - | -| 25 | `ResponseReasoningSummaryPartAddedEvent` | - | -| 26 | `ResponseReasoningSummaryPartDoneEvent` | - | -| 27 | `ResponseReasoningSummaryTextDeltaEvent` | - | -| 28 | `ResponseReasoningSummaryTextDoneEvent` | - | -| 29 | `ResponseReasoningTextDeltaEvent` | - | -| 30 | `ResponseReasoningTextDoneEvent` | - | -| 31 | `ResponseRefusalDeltaEvent` | - | -| 32 | `ResponseRefusalDoneEvent` | - | -| 33 | `ResponseTextDeltaEvent` | - | -| 34 | `ResponseTextDoneEvent` | - | -| 35 | `ResponseWebSearchCallCompletedEvent` | - | -| 36 | `ResponseWebSearchCallInProgressEvent` | - | -| 37 | `ResponseWebSearchCallSearchingEvent` | - | -| 38 | `ResponseImageGenCallCompletedEvent` | - | -| 39 | `ResponseImageGenCallGeneratingEvent` | - | -| 40 | `ResponseImageGenCallInProgressEvent` | - | -| 41 | `ResponseImageGenCallPartialImageEvent` | - | -| 42 | `ResponseMCPCallArgumentsDeltaEvent` | - | -| 43 | `ResponseMCPCallArgumentsDoneEvent` | - | -| 44 | `ResponseMCPCallCompletedEvent` | - | -| 45 | `ResponseMCPCallFailedEvent` | - | -| 46 | `ResponseMCPCallInProgressEvent` | - | -| 47 | `ResponseMCPListToolsCompletedEvent` | - | -| 48 | `ResponseMCPListToolsFailedEvent` | - | -| 49 | `ResponseMCPListToolsInProgressEvent` | - | -| 50 | `ResponseOutputTextAnnotationAddedEvent` | - | -| 51 | `ResponseQueuedEvent` | - | -| 52 | `ResponseCustomToolCallInputDeltaEvent` | - | -| 53 | `ResponseCustomToolCallInputDoneEvent` | - | - -### `ResponseStreamOptions` - -| 项 | 值 | -| --- | --- | -| 类型 | `object \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object` | Options for streaming responses. Only set this when you set stream: true. | -| 2 | `null` | - | - -### `ResponseTextDeltaEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when there is an additional text delta. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the content part that the text delta was added to. | -| `delta` | 是 | `string` | - | The text delta that was added. | -| `item_id` | 是 | `string` | - | The ID of the output item that the text delta was added to. | -| `logprobs` | 是 | `array` | - | The log probabilities of the tokens in the delta. | -| `output_index` | 是 | `integer` | - | The index of the output item that the text delta was added to. | -| `sequence_number` | 是 | `integer` | - | The sequence number for this event. | -| `type` | 是 | `string` | `response.output_text.delta` | The type of the event. Always response.output_text.delta. | - -### `ResponseTextDoneEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when text content is finalized. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content_index` | 是 | `integer` | - | The index of the content part that the text content is finalized. | -| `item_id` | 是 | `string` | - | The ID of the output item that the text content is finalized. | -| `logprobs` | 是 | `array` | - | The log probabilities of the tokens in the delta. | -| `output_index` | 是 | `integer` | - | The index of the output item that the text content is finalized. | -| `sequence_number` | 是 | `integer` | - | The sequence number for this event. | -| `text` | 是 | `string` | - | The text content that is finalized. | -| `type` | 是 | `string` | `response.output_text.done` | The type of the event. Always response.output_text.done. | - -### `ResponseTextParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration options for a text response from the model. Can be plain text or structured JSON data. Learn more: - [Text inputs and outputs](/docs/guides/text) - [Structured Outpu… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `format` | 否 | `TextResponseFormatConfiguration` | - | - | -| `verbosity` | 否 | `Verbosity` | - | - | - -### `ResponseUsage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents token usage details including input tokens, output tokens, a breakdown of output tokens, and the total tokens used. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `input_tokens` | 是 | `integer` | - | The number of input tokens. | -| `input_tokens_details` | 是 | `object` | - | A detailed breakdown of the input tokens. | -| `output_tokens` | 是 | `integer` | - | The number of output tokens. | -| `output_tokens_details` | 是 | `object` | - | A detailed breakdown of the output tokens. | -| `total_tokens` | 是 | `integer` | - | The total number of tokens used. | - -### `ResponseWebSearchCallCompletedEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a web search call is completed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | Unique ID for the output item associated with the web search call. | -| `output_index` | 是 | `integer` | - | The index of the output item that the web search call is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of the web search call being processed. | -| `type` | 是 | `string` | `response.web_search_call.completed` | The type of the event. Always response.web_search_call.completed. | - -### `ResponseWebSearchCallInProgressEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a web search call is initiated. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | Unique ID for the output item associated with the web search call. | -| `output_index` | 是 | `integer` | - | The index of the output item that the web search call is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of the web search call being processed. | -| `type` | 是 | `string` | `response.web_search_call.in_progress` | The type of the event. Always response.web_search_call.in_progress. | - -### `ResponseWebSearchCallSearchingEvent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Emitted when a web search call is executing. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `item_id` | 是 | `string` | - | Unique ID for the output item associated with the web search call. | -| `output_index` | 是 | `integer` | - | The index of the output item that the web search call is associated with. | -| `sequence_number` | 是 | `integer` | - | The sequence number of the web search call being processed. | -| `type` | 是 | `string` | `response.web_search_call.searching` | The type of the event. Always response.web_search_call.searching. | - -### `ScreenshotParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A screenshot action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `screenshot` | Specifies the event type. For a screenshot action, this property is always set to screenshot. | - -### `ScrollParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A scroll action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `keys` | 否 | `array \| null` | - | - | -| `scroll_x` | 是 | `integer` | - | The horizontal scroll distance. | -| `scroll_y` | 是 | `integer` | - | The vertical scroll distance. | -| `type` | 是 | `string` | `scroll` | Specifies the event type. For a scroll action, this property is always set to scroll. | -| `x` | 是 | `integer` | - | The x-coordinate where the scroll occurred. | -| `y` | 是 | `integer` | - | The y-coordinate where the scroll occurred. | - -### `SearchContentType` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `SearchContextSize` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ServiceTier` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | Specifies the processing type used for serving the request. - If set to 'auto', then the request will be processed with the service tier configured in the Project settings. Unless… | -| 2 | `null` | - | - -### `ServiceTierEnum` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `SkillReferenceParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `skill_id` | 是 | `string` | - | The ID of the referenced skill. | -| `type` | 是 | `string` | `skill_reference` | References a skill created with the /v1/skills endpoint. | -| `version` | 否 | `string` | - | Optional skill version. Use a positive integer or 'latest'. Omit for default. | - -### `SpecificApplyPatchParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Forces the model to call the apply_patch tool when executing a tool call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `apply_patch` | The tool to call. Always apply_patch. | - -### `SpecificFunctionShellParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Forces the model to call the shell tool when a tool call is required. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `shell` | The tool to call. Always shell. | - -### `StopConfiguration` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| array` | -| 说明 | Not supported with latest reasoning models o3 and o4-mini. Up to 4 sequences where the API will stop generating further tokens. The returned text will not contain the stop sequenc… | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | - | -| 2 | `array` | - | - -### `SummaryTextContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A summary text from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | A summary of the reasoning output from the model so far. | -| `type` | 是 | `string` | `summary_text` | The type of the object. Always summary_text. | - -### `TextContent` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A text content. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | - | -| `type` | 是 | `string` | `text` | - | - -### `TextResponseFormatConfiguration` - -| 项 | 值 | -| --- | --- | -| 类型 | `ResponseFormatText \| TextResponseFormatJsonSchema \| ResponseFormatJsonObject` | -| 说明 | An object specifying the format that the model must output. Configuring { "type": "json_schema" } enables Structured Outputs, which ensures the model will match your supplied JSON… | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ResponseFormatText` | - | -| 2 | `TextResponseFormatJsonSchema` | - | -| 3 | `ResponseFormatJsonObject` | - | - -### `TextResponseFormatJsonSchema` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | JSON Schema response format. Used to generate structured JSON responses. Learn more about [Structured Outputs](/docs/guides/structured-outputs). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 否 | `string` | - | A description of what the response format is for, used by the model to determine how to respond in the format. | -| `name` | 是 | `string` | - | The name of the response format. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. | -| `schema` | 是 | `ResponseFormatJsonSchemaSchema` | - | - | -| `strict` | 否 | `boolean \| null` | - | - | -| `type` | 是 | `string` | `json_schema` | The type of response format being defined. Always json_schema. | - -### `Tool` - -| 项 | 值 | -| --- | --- | -| 类型 | `FunctionTool \| FileSearchTool \| ComputerTool \| ComputerUsePreviewTool \| WebSearchTool \| MCPTool \| CodeInterpreterTool \| ImageGenTool … (+7)` | -| 说明 | A tool that can be used to generate a response. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `FunctionTool` | - | -| 2 | `FileSearchTool` | - | -| 3 | `ComputerTool` | - | -| 4 | `ComputerUsePreviewTool` | - | -| 5 | `WebSearchTool` | - | -| 6 | `MCPTool` | - | -| 7 | `CodeInterpreterTool` | - | -| 8 | `ImageGenTool` | - | -| 9 | `LocalShellToolParam` | - | -| 10 | `FunctionShellToolParam` | - | -| 11 | `CustomToolParam` | - | -| 12 | `NamespaceToolParam` | - | -| 13 | `ToolSearchToolParam` | - | -| 14 | `WebSearchPreviewTool` | - | -| 15 | `ApplyPatchToolParam` | - | - -### `ToolChoiceAllowed` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Constrains the tools available to the model to a pre-defined set. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `mode` | 是 | `string` | `auto`, `required` | Constrains the tools available to the model to a pre-defined set. auto allows the model to pick from among the allowed tools and generate a message. required requires the model to… | -| `tools` | 是 | `array` | - | A list of tool definitions that the model should be allowed to call. For the Responses API, the list of tool definitions might look like: | -| `type` | 是 | `string` | `allowed_tools` | Allowed tool configuration type. Always allowed_tools. | - -### `ToolChoiceCustom` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Use this option to force the model to call a specific custom tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `name` | 是 | `string` | - | The name of the custom tool to call. | -| `type` | 是 | `string` | `custom` | For custom tool calling, the type is always custom. | - -### `ToolChoiceFunction` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Use this option to force the model to call a specific function. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `name` | 是 | `string` | - | The name of the function to call. | -| `type` | 是 | `string` | `function` | For function calling, the type is always function. | - -### `ToolChoiceMCP` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Use this option to force the model to call a specific tool on a remote MCP server. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `name` | 否 | `string \| null` | - | - | -| `server_label` | 是 | `string` | - | The label of the MCP server to use. | -| `type` | 是 | `string` | `mcp` | For MCP tools, the type is always mcp. | - -### `ToolChoiceOptions` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | Controls which (if any) tool is called by the model. none means the model will not call any tool and instead generates a message. auto means the model can pick between generating … | - -### `ToolChoiceParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `ToolChoiceOptions \| ToolChoiceAllowed \| ToolChoiceTypes \| ToolChoiceFunction \| ToolChoiceMCP \| ToolChoiceCustom \| SpecificApplyPatchParam \| SpecificFunctionShellParam` | -| 说明 | How the model should select which tool (or tools) to use when generating a response. See the tools parameter to see how to specify which tools the model can call. | -| 组合 | `oneOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `ToolChoiceOptions` | - | -| 2 | `ToolChoiceAllowed` | - | -| 3 | `ToolChoiceTypes` | - | -| 4 | `ToolChoiceFunction` | - | -| 5 | `ToolChoiceMCP` | - | -| 6 | `ToolChoiceCustom` | - | -| 7 | `SpecificApplyPatchParam` | - | -| 8 | `SpecificFunctionShellParam` | - | - -### `ToolChoiceTypes` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Indicates that the model should use a built-in tool to generate a response. [Learn more about built-in tools](/docs/guides/tools). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `file_search`, `web_search_preview`, `computer`, `computer_use_preview`, `computer_use`, `web_search_preview_2025_03_11`, `image_generation`, `code_interpreter` | The type of hosted tool the model should to use. Learn more about [built-in tools](/docs/guides/tools). Allowed values are: - file_search - web_search_preview - computer - compute… | - -### `ToolSearchCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `arguments` | 是 | `object/value` | - | Arguments used for the tool search call. | -| `call_id` | 是 | `string \| null` | - | - | -| `created_by` | 否 | `string` | - | The identifier of the actor that created the item. | -| `execution` | 是 | `ToolSearchExecutionType` | - | Whether tool search was executed by the server or by the client. | -| `id` | 是 | `string` | - | The unique ID of the tool search call item. | -| `status` | 是 | `FunctionCallStatus` | - | The status of the tool search call item that was recorded. | -| `type` | 是 | `string` | `tool_search_call` | The type of the item. Always tool_search_call. | - -### `ToolSearchCallItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `arguments` | 是 | `EmptyModelParam` | - | The arguments supplied to the tool search call. | -| `call_id` | 否 | `string \| null` | - | - | -| `execution` | 否 | `ToolSearchExecutionType` | - | Whether tool search was executed by the server or by the client. | -| `id` | 否 | `string \| null` | - | - | -| `status` | 否 | `FunctionCallItemStatus \| null` | - | - | -| `type` | 是 | `string` | `tool_search_call` | The item type. Always tool_search_call. | - -### `ToolSearchExecutionType` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | - | - -### `ToolSearchOutput` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 是 | `string \| null` | - | - | -| `created_by` | 否 | `string` | - | The identifier of the actor that created the item. | -| `execution` | 是 | `ToolSearchExecutionType` | - | Whether tool search was executed by the server or by the client. | -| `id` | 是 | `string` | - | The unique ID of the tool search output item. | -| `status` | 是 | `FunctionCallOutputStatusEnum` | - | The status of the tool search output item that was recorded. | -| `tools` | 是 | `array` | - | The loaded tool definitions returned by tool search. | -| `type` | 是 | `string` | `tool_search_output` | The type of the item. Always tool_search_output. | - -### `ToolSearchOutputItemParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | - | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `call_id` | 否 | `string \| null` | - | - | -| `execution` | 否 | `ToolSearchExecutionType` | - | Whether tool search was executed by the server or by the client. | -| `id` | 否 | `string \| null` | - | - | -| `status` | 否 | `FunctionCallItemStatus \| null` | - | - | -| `tools` | 是 | `array` | - | The loaded tool definitions returned by the tool search output. | -| `type` | 是 | `string` | `tool_search_output` | The item type. Always tool_search_output. | - -### `ToolSearchToolParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Hosted or BYOT tool search configuration for deferred tools. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `description` | 否 | `string \| null` | - | - | -| `execution` | 否 | `ToolSearchExecutionType` | - | Whether tool search is executed by the server or by the client. | -| `parameters` | 否 | `EmptyModelParam \| null` | - | - | -| `type` | 是 | `string` | `tool_search` | The type of the tool. Always tool_search. | - -### `ToolsArray` - -| 项 | 值 | -| --- | --- | -| 类型 | `array` | -| 说明 | An array of tools the model may call while generating a response. You can specify which tool to use by setting the tool_choice parameter. We support the following categories of to… | - -### `TopLogProb` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The top log probability of a token. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `bytes` | 是 | `array` | - | - | -| `logprob` | 是 | `number` | - | - | -| `token` | 是 | `string` | - | - | - -### `TypeParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An action to type in text. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `text` | 是 | `string` | - | The text to type. | -| `type` | 是 | `string` | `type` | Specifies the event type. For a type action, this property is always set to type. | - -### `UrlCitationBody` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A citation for a web resource used to generate a model response. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `end_index` | 是 | `integer` | - | The index of the last character of the URL citation in the message. | -| `start_index` | 是 | `integer` | - | The index of the first character of the URL citation in the message. | -| `title` | 是 | `string` | - | The title of the web resource. | -| `type` | 是 | `string` | `url_citation` | The type of the URL citation. Always url_citation. | -| `url` | 是 | `string(uri)` | - | The URL of the web resource. | - -### `VectorStoreFileAttributes` - -| 项 | 值 | -| --- | --- | -| 类型 | `object/map \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object/map` | Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format, and querying for object… | -| 2 | `null` | - | - -### `Verbosity` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | Constrains the verbosity of the model's response. Lower values will result in more concise responses, while higher values will result in more verbose responses. Currently supporte… | -| 2 | `null` | - | - -### `VoiceIdsOrCustomVoice` - -| 项 | 值 | -| --- | --- | -| 类型 | `VoiceIdsShared \| object` | -| 说明 | A built-in voice name or a custom voice reference. | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `VoiceIdsShared` | - | -| 2 | `object` | Custom voice reference. | - -### `VoiceIdsShared` - -| 项 | 值 | -| --- | --- | -| 类型 | `string \| string` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `string` | - | -| 2 | `string` | - | - -### `WaitParam` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A wait action. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `wait` | Specifies the event type. For a wait action, this property is always set to wait. | - -### `WebSearchActionFind` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Action type "find_in_page": Searches for a pattern within a loaded page. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `pattern` | 是 | `string` | - | The pattern or text to search for within the page. | -| `type` | 是 | `string` | `find_in_page` | The action type. | -| `url` | 是 | `string(uri)` | - | The URL of the page searched for the pattern. | - -### `WebSearchActionOpenPage` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Action type "open_page" - Opens a specific URL from search results. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `type` | 是 | `string` | `open_page` | The action type. | -| `url` | 否 | `string(uri) \| null` | - | The URL opened by the model. | - -### `WebSearchActionSearch` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Action type "search" - Performs a web search query. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `queries` | 否 | `array` | - | The search queries. | -| `query` | 否 | `string` | - | The search query. | -| `sources` | 否 | `array` | - | The sources used in the search. | -| `type` | 是 | `string` | `search` | The action type. | - -### `WebSearchApproximateLocation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object \| null` | -| 说明 | - | -| 组合 | `anyOf` | - -| 变体 | 类型 | 说明 | -| --- | --- | --- | -| 1 | `object` | The approximate location of the user. | -| 2 | `null` | - | - -### `WebSearchContextSize` - -| 项 | 值 | -| --- | --- | -| 类型 | `string` | -| 说明 | High level guidance for the amount of context window space to use for the search. One of low, medium, or high. medium is the default. | - -### `WebSearchLocation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Approximate location parameters for the search. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `city` | 否 | `string` | - | Free text input for the city of the user, e.g. San Francisco. | -| `country` | 否 | `string` | - | The two-letter [ISO country code](https://en.wikipedia.org/wiki/ISO_3166-1) of the user, e.g. US. | -| `region` | 否 | `string` | - | Free text input for the region of the user, e.g. California. | -| `timezone` | 否 | `string` | - | The [IANA timezone](https://timeapi.io/documentation/iana-timezones) of the user, e.g. America/Los_Angeles. | - -### `WebSearchPreviewTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | This tool searches the web for relevant results to use in a response. Learn more about the [web search tool](https://platform.openai.com/docs/guides/tools-web-search). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `search_content_types` | 否 | `array` | - | - | -| `search_context_size` | 否 | `SearchContextSize` | - | High level guidance for the amount of context window space to use for the search. One of low, medium, or high. medium is the default. | -| `type` | 是 | `string` | `web_search_preview`, `web_search_preview_2025_03_11` | The type of the web search tool. One of web_search_preview or web_search_preview_2025_03_11. | -| `user_location` | 否 | `ApproximateLocation \| null` | - | - | - -### `WebSearchTool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Search the Internet for sources related to the prompt. Learn more about the [web search tool](/docs/guides/tools-web-search). | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `filters` | 否 | `object \| null` | - | - | -| `search_context_size` | 否 | `string` | `low`, `medium`, `high` | High level guidance for the amount of context window space to use for the search. One of low, medium, or high. medium is the default. | -| `type` | 是 | `string` | `web_search`, `web_search_2025_08_26` | The type of the web search tool. One of web_search or web_search_2025_08_26. | -| `user_location` | 否 | `WebSearchApproximateLocation` | - | - | - -### `WebSearchToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The results of a web search tool call. See the [web search guide](/docs/guides/tools-web-search) for more information. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `action` | 是 | `WebSearchActionSearch \| WebSearchActionOpenPage \| WebSearchActionFind` | - | An object describing the specific action taken in this web search call. Includes details on how the model used the web (search, open_page, find_in_page). | -| `id` | 是 | `string` | - | The unique ID of the web search tool call. | -| `status` | 是 | `string` | `in_progress`, `searching`, `completed`, `failed` | The status of the web search tool call. | -| `type` | 是 | `string` | `web_search_call` | The type of the web search tool call. Always web_search_call. | - -## Claude / Anthropic Endpoints - -| Method | Path | Request schema | Response schema | 说明 | -| --- | --- | --- | --- | --- | -| POST | `/v1/messages` | `MessageCreateParams` | `Message` 或 `RawMessageStreamEvent` | 创建非流式或 SSE 流式消息 | -| POST | `/v1/messages/count_tokens` | `MessageCountTokensParams` | `MessageTokensCount` | 仅计数,不生成消息 | - -## Claude / Anthropic TypeScript 字段表 - -以下类型从 Anthropic 官方 TypeScript SDK 的 Messages 资源类型递归引用得到,共 164 个。TypeScript union 中的 server tool 版本号是官方 SDK 暴露的字面量类型,实际可用性仍取决于 Anthropic 账号、模型和 beta 配置。 - -### `Base64ImageSource` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `data` | 是 | `string` | -| `media_type` | 是 | `'image/jpeg' \| 'image/png' \| 'image/gif' \| 'image/webp'` | -| `type` | 是 | `'base64'` | - -### `Base64PDFSource` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `data` | 是 | `string` | -| `media_type` | 是 | `'application/pdf'` | -| `type` | 是 | `'base64'` | - -### `BashCodeExecutionOutputBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `file_id` | 是 | `string` | -| `type` | 是 | `'bash_code_execution_output'` | - -### `BashCodeExecutionOutputBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `file_id` | 是 | `string` | -| `type` | 是 | `'bash_code_execution_output'` | - -### `BashCodeExecutionResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `return_code` | 是 | `number` | -| `stderr` | 是 | `string` | -| `stdout` | 是 | `string` | -| `type` | 是 | `'bash_code_execution_result'` | - -### `BashCodeExecutionResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `return_code` | 是 | `number` | -| `stderr` | 是 | `string` | -| `stdout` | 是 | `string` | -| `type` | 是 | `'bash_code_execution_result'` | - -### `BashCodeExecutionToolResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `BashCodeExecutionToolResultError \| BashCodeExecutionResultBlock` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'bash_code_execution_tool_result'` | - -### `BashCodeExecutionToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `BashCodeExecutionToolResultErrorParam \| BashCodeExecutionResultBlockParam` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'bash_code_execution_tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `BashCodeExecutionToolResultError` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `BashCodeExecutionToolResultErrorCode` | -| `type` | 是 | `'bash_code_execution_tool_result_error'` | - -### `BashCodeExecutionToolResultErrorCode` - -类型别名:`\| 'invalid_tool_input' \| 'unavailable' \| 'too_many_requests' \| 'execution_time_exceeded' \| 'output_file_too_large'` - -### `BashCodeExecutionToolResultErrorParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `BashCodeExecutionToolResultErrorCode` | -| `type` | 是 | `'bash_code_execution_tool_result_error'` | - -### `CacheControlEphemeral` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'ephemeral'` | -| `ttl` | 否 | `'5m' \| '1h'` | - -### `CacheCreation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `ephemeral_1h_input_tokens` | 是 | `number` | -| `ephemeral_5m_input_tokens` | 是 | `number` | - -### `CitationCharLocation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `document_index` | 是 | `number` | -| `document_title` | 是 | `string \| null` | -| `end_char_index` | 是 | `number` | -| `file_id` | 是 | `string \| null` | -| `start_char_index` | 是 | `number` | -| `type` | 是 | `'char_location'` | - -### `CitationCharLocationParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `document_index` | 是 | `number` | -| `document_title` | 是 | `string \| null` | -| `end_char_index` | 是 | `number` | -| `start_char_index` | 是 | `number` | -| `type` | 是 | `'char_location'` | - -### `CitationContentBlockLocation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `document_index` | 是 | `number` | -| `document_title` | 是 | `string \| null` | -| `end_block_index` | 是 | `number` | -| `file_id` | 是 | `string \| null` | -| `start_block_index` | 是 | `number` | -| `type` | 是 | `'content_block_location'` | - -### `CitationContentBlockLocationParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `document_index` | 是 | `number` | -| `document_title` | 是 | `string \| null` | -| `end_block_index` | 是 | `number` | -| `start_block_index` | 是 | `number` | -| `type` | 是 | `'content_block_location'` | - -### `CitationPageLocation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `document_index` | 是 | `number` | -| `document_title` | 是 | `string \| null` | -| `end_page_number` | 是 | `number` | -| `file_id` | 是 | `string \| null` | -| `start_page_number` | 是 | `number` | -| `type` | 是 | `'page_location'` | - -### `CitationPageLocationParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `document_index` | 是 | `number` | -| `document_title` | 是 | `string \| null` | -| `end_page_number` | 是 | `number` | -| `start_page_number` | 是 | `number` | -| `type` | 是 | `'page_location'` | - -### `CitationSearchResultLocationParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `end_block_index` | 是 | `number` | -| `search_result_index` | 是 | `number` | -| `source` | 是 | `string` | -| `start_block_index` | 是 | `number` | -| `title` | 是 | `string \| null` | -| `type` | 是 | `'search_result_location'` | - -### `CitationWebSearchResultLocationParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `encrypted_index` | 是 | `string` | -| `title` | 是 | `string \| null` | -| `type` | 是 | `'web_search_result_location'` | -| `url` | 是 | `string` | - -### `CitationsConfig` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `enabled` | 是 | `boolean` | - -### `CitationsConfigParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `enabled` | 否 | `boolean` | - -### `CitationsDelta` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `citation` | 是 | `\| CitationCharLocation \| CitationPageLocation \| CitationContentBlockLocation \| CitationsWebSearchResultLocation \| CitationsSearchResultLocation` | -| `type` | 是 | `'citations_delta'` | - -### `CitationsSearchResultLocation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `end_block_index` | 是 | `number` | -| `search_result_index` | 是 | `number` | -| `source` | 是 | `string` | -| `start_block_index` | 是 | `number` | -| `title` | 是 | `string \| null` | -| `type` | 是 | `'search_result_location'` | - -### `CitationsWebSearchResultLocation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cited_text` | 是 | `string` | -| `encrypted_index` | 是 | `string` | -| `title` | 是 | `string \| null` | -| `type` | 是 | `'web_search_result_location'` | -| `url` | 是 | `string` | - -### `CodeExecutionOutputBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `file_id` | 是 | `string` | -| `type` | 是 | `'code_execution_output'` | - -### `CodeExecutionOutputBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `file_id` | 是 | `string` | -| `type` | 是 | `'code_execution_output'` | - -### `CodeExecutionResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `return_code` | 是 | `number` | -| `stderr` | 是 | `string` | -| `stdout` | 是 | `string` | -| `type` | 是 | `'code_execution_result'` | - -### `CodeExecutionResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `return_code` | 是 | `number` | -| `stderr` | 是 | `string` | -| `stdout` | 是 | `string` | -| `type` | 是 | `'code_execution_result'` | - -### `CodeExecutionTool20250522` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'code_execution'` | -| `type` | 是 | `'code_execution_20250522'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `strict` | 否 | `boolean` | - -### `CodeExecutionTool20250825` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'code_execution'` | -| `type` | 是 | `'code_execution_20250825'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `strict` | 否 | `boolean` | - -### `CodeExecutionTool20260120` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'code_execution'` | -| `type` | 是 | `'code_execution_20260120'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `strict` | 否 | `boolean` | - -### `CodeExecutionToolResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `CodeExecutionToolResultBlockContent` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'code_execution_tool_result'` | - -### `CodeExecutionToolResultBlockContent` - -类型别名:`\| CodeExecutionToolResultError \| CodeExecutionResultBlock \| EncryptedCodeExecutionResultBlock` - -### `CodeExecutionToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `CodeExecutionToolResultBlockParamContent` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'code_execution_tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `CodeExecutionToolResultBlockParamContent` - -类型别名:`\| CodeExecutionToolResultErrorParam \| CodeExecutionResultBlockParam \| EncryptedCodeExecutionResultBlockParam` - -### `CodeExecutionToolResultError` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `CodeExecutionToolResultErrorCode` | -| `type` | 是 | `'code_execution_tool_result_error'` | - -### `CodeExecutionToolResultErrorCode` - -类型别名:`\| 'invalid_tool_input' \| 'unavailable' \| 'too_many_requests' \| 'execution_time_exceeded'` - -### `CodeExecutionToolResultErrorParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `CodeExecutionToolResultErrorCode` | -| `type` | 是 | `'code_execution_tool_result_error'` | - -### `Container` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `id` | 是 | `string` | -| `expires_at` | 是 | `string` | - -### `ContainerUploadBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `file_id` | 是 | `string` | -| `type` | 是 | `'container_upload'` | - -### `ContainerUploadBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `file_id` | 是 | `string` | -| `type` | 是 | `'container_upload'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `ContentBlock` - -类型别名:`\| TextBlock \| ThinkingBlock \| RedactedThinkingBlock \| ToolUseBlock \| ServerToolUseBlock \| WebSearchToolResultBlock \| WebFetchToolResultBlock \| CodeExecutionToolResultBlock \| BashCodeExecutionToolResultBlock \| TextEditorCodeExecutionToolResultBlock \| ToolSearchToolResultBlock \| ContainerUploadBlock` - -### `ContentBlockParam` - -类型别名:`\| TextBlockParam \| ImageBlockParam \| DocumentBlockParam \| SearchResultBlockParam \| ThinkingBlockParam \| RedactedThinkingBlockParam \| ToolUseBlockParam \| ToolResultBlockParam \| ServerToolUseBlockParam \| WebSearchToolResultBlockParam \| WebFetchToolResultBlockParam \| CodeExecutionToolResultBlockParam \| BashCodeExecutionToolResultBlockParam \| TextEditorCodeExecutionToolResultBlockParam \| ToolSearchToolResultBlockParam \| ContainerUploadBlockParam \| MidConversationSystemBlockParam` - -### `ContentBlockSource` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `string \| Array` | -| `type` | 是 | `'content'` | - -### `ContentBlockSourceContent` - -类型别名:`TextBlockParam \| ImageBlockParam` - -### `DirectCaller` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'direct'` | - -### `DocumentBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `citations` | 是 | `CitationsConfig \| null` | -| `source` | 是 | `Base64PDFSource \| PlainTextSource` | -| `title` | 是 | `string \| null` | -| `type` | 是 | `'document'` | - -### `DocumentBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `source` | 是 | `Base64PDFSource \| PlainTextSource \| ContentBlockSource \| URLPDFSource` | -| `type` | 是 | `'document'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `citations` | 否 | `CitationsConfigParam \| null` | -| `context` | 否 | `string \| null` | -| `title` | 否 | `string \| null` | - -### `EncryptedCodeExecutionResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `encrypted_stdout` | 是 | `string` | -| `return_code` | 是 | `number` | -| `stderr` | 是 | `string` | -| `type` | 是 | `'encrypted_code_execution_result'` | - -### `EncryptedCodeExecutionResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `encrypted_stdout` | 是 | `string` | -| `return_code` | 是 | `number` | -| `stderr` | 是 | `string` | -| `type` | 是 | `'encrypted_code_execution_result'` | - -### `ImageBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `source` | 是 | `Base64ImageSource \| URLImageSource` | -| `type` | 是 | `'image'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `InputJSONDelta` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `partial_json` | 是 | `string` | -| `type` | 是 | `'input_json_delta'` | - -### `JSONOutputFormat` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `schema` | 是 | `{ [key: string]: unknown }` | -| `type` | 是 | `'json_schema'` | - -### `MemoryTool20250818` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'memory'` | -| `type` | 是 | `'memory_20250818'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | -| `strict` | 否 | `boolean` | - -### `Message` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `id` | 是 | `string` | -| `container` | 是 | `Container \| null` | -| `content` | 是 | `Array` | -| `model` | 是 | `Model` | -| `role` | 是 | `'assistant'` | -| `stop_details` | 是 | `RefusalStopDetails \| null` | -| `stop_reason` | 是 | `StopReason \| null` | -| `stop_sequence` | 是 | `string \| null` | -| `type` | 是 | `'message'` | -| `usage` | 是 | `Usage` | - -### `MessageCountTokensParams` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `messages` | 是 | `Array` | -| `model` | 是 | `Model` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `output_config` | 否 | `OutputConfig` | -| `system` | 否 | `string \| Array` | -| `thinking` | 否 | `ThinkingConfigParam` | -| `tool_choice` | 否 | `ToolChoice` | -| `tools` | 否 | `Array` | - -### `MessageCountTokensTool` - -类型别名:`\| Tool \| ToolBash20250124 \| CodeExecutionTool20250522 \| CodeExecutionTool20250825 \| CodeExecutionTool20260120 \| MemoryTool20250818 \| ToolTextEditor20250124 \| ToolTextEditor20250429 \| ToolTextEditor20250728 \| WebSearchTool20250305 \| WebFetchTool20250910 \| WebSearchTool20260209 \| WebFetchTool20260209 \| WebFetchTool20260309 \| ToolSearchToolBm25_20251119 \| ToolSearchToolRegex20251119` - -### `MessageCreateParams` - -类型别名:`MessageCreateParamsNonStreaming \| MessageCreateParamsStreaming` - -### `MessageCreateParamsBase` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `max_tokens` | 是 | `number` | -| `messages` | 是 | `Array` | -| `model` | 是 | `Model` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `container` | 否 | `string \| null` | -| `inference_geo` | 否 | `string \| null` | -| `metadata` | 否 | `Metadata` | -| `output_config` | 否 | `OutputConfig` | -| `service_tier` | 否 | `'auto' \| 'standard_only'` | -| `stop_sequences` | 否 | `Array` | -| `stream` | 否 | `boolean` | -| `system` | 否 | `string \| Array` | -| `temperature` | 否 | `number` | -| `thinking` | 否 | `ThinkingConfigParam` | -| `tool_choice` | 否 | `ToolChoice` | -| `tools` | 否 | `Array` | -| `top_k` | 否 | `number` | -| `top_p` | 否 | `number` | - -### `MessageCreateParamsNonStreaming` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `stream` | 否 | `false` | - -### `MessageCreateParamsStreaming` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `stream` | 是 | `true` | - -### `MessageDeltaUsage` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cache_creation_input_tokens` | 是 | `number \| null` | -| `cache_read_input_tokens` | 是 | `number \| null` | -| `input_tokens` | 是 | `number \| null` | -| `output_tokens` | 是 | `number` | -| `output_tokens_details` | 是 | `OutputTokensDetails \| null` | -| `server_tool_use` | 是 | `ServerToolUsage \| null` | - -### `MessageParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `string \| Array` | -| `role` | 是 | `'user' \| 'assistant' \| 'system'` | - -### `MessageTokensCount` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `input_tokens` | 是 | `number` | - -### `Metadata` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `user_id` | 否 | `string \| null` | - -### `MidConversationSystemBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `type` | 是 | `'mid_conv_system'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `Model` - -类型别名:`\| 'claude-opus-4-8' \| 'claude-opus-4-7' \| 'claude-mythos-preview' \| 'claude-opus-4-6' \| 'claude-sonnet-4-6' \| 'claude-haiku-4-5' \| 'claude-haiku-4-5-20251001' \| 'claude-opus-4-5' \| 'claude-opus-4-5-20251101' \| 'claude-sonnet-4-5' \| 'claude-sonnet-4-5-20250929' \| 'claude-opus-4-1' \| 'claude-opus-4-1-20250805' \| 'claude-opus-4-0' \| 'claude-opus-4-20250514' \| 'claude-sonnet-4-0' \| 'claude-sonnet-4-20250514' \| 'claude-3-haiku-20240307' \| (string & {})` - -### `OutputConfig` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `effort` | 否 | `'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max' \| null` | -| `format` | 否 | `JSONOutputFormat \| null` | - -### `OutputTokensDetails` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `thinking_tokens` | 是 | `number` | - -### `PlainTextSource` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `data` | 是 | `string` | -| `media_type` | 是 | `'text/plain'` | -| `type` | 是 | `'text'` | - -### `RawContentBlockDelta` - -类型别名:`\| TextDelta \| InputJSONDelta \| CitationsDelta \| ThinkingDelta \| SignatureDelta` - -### `RawContentBlockDeltaEvent` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `delta` | 是 | `RawContentBlockDelta` | -| `index` | 是 | `number` | -| `type` | 是 | `'content_block_delta'` | - -### `RawContentBlockStartEvent` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content_block` | 是 | `\| TextBlock \| ThinkingBlock \| RedactedThinkingBlock \| ToolUseBlock \| ServerToolUseBlock \| WebSearchToolResultBlock \| WebFetchToolResultBlock \| CodeExecutionToolResultBlock \| BashCodeExecutionToolResultBlock \| TextEditorCodeExecutionToolResultBlock \| ToolSearchToolResultBlock \| ContainerUploadBlock` | -| `index` | 是 | `number` | -| `type` | 是 | `'content_block_start'` | - -### `RawContentBlockStopEvent` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `index` | 是 | `number` | -| `type` | 是 | `'content_block_stop'` | - -### `RawMessageDeltaEvent` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `delta` | 是 | `RawMessageDeltaEvent.Delta` | -| `type` | 是 | `'message_delta'` | -| `usage` | 是 | `MessageDeltaUsage` | - -### `RawMessageStartEvent` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `message` | 是 | `Message` | -| `type` | 是 | `'message_start'` | - -### `RawMessageStopEvent` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'message_stop'` | - -### `RawMessageStreamEvent` - -类型别名:`\| RawMessageStartEvent \| RawMessageDeltaEvent \| RawMessageStopEvent \| RawContentBlockStartEvent \| RawContentBlockDeltaEvent \| RawContentBlockStopEvent` - -### `RedactedThinkingBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `data` | 是 | `string` | -| `type` | 是 | `'redacted_thinking'` | - -### `RedactedThinkingBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `data` | 是 | `string` | -| `type` | 是 | `'redacted_thinking'` | - -### `RefusalStopDetails` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `category` | 是 | `'cyber' \| 'bio' \| null` | -| `explanation` | 是 | `string \| null` | -| `type` | 是 | `'refusal'` | - -### `SearchResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `Array` | -| `source` | 是 | `string` | -| `title` | 是 | `string` | -| `type` | 是 | `'search_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `citations` | 否 | `CitationsConfigParam` | - -### `ServerToolCaller` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_id` | 是 | `string` | -| `type` | 是 | `'code_execution_20250825'` | - -### `ServerToolCaller20260120` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_id` | 是 | `string` | -| `type` | 是 | `'code_execution_20260120'` | - -### `ServerToolUsage` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `web_fetch_requests` | 是 | `number` | -| `web_search_requests` | 是 | `number` | - -### `ServerToolUseBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `id` | 是 | `string` | -| `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | -| `input` | 是 | `unknown` | -| `name` | 是 | `\| 'web_search' \| 'web_fetch' \| 'code_execution' \| 'bash_code_execution' \| 'text_editor_code_execution' \| 'tool_search_tool_regex' \| 'tool_search_tool_bm25'` | -| `type` | 是 | `'server_tool_use'` | - -### `ServerToolUseBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `id` | 是 | `string` | -| `input` | 是 | `unknown` | -| `name` | 是 | `\| 'web_search' \| 'web_fetch' \| 'code_execution' \| 'bash_code_execution' \| 'text_editor_code_execution' \| 'tool_search_tool_regex' \| 'tool_search_tool_bm25'` | -| `type` | 是 | `'server_tool_use'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | - -### `SignatureDelta` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `signature` | 是 | `string` | -| `type` | 是 | `'signature_delta'` | - -### `StopReason` - -类型别名:`'end_turn' \| 'max_tokens' \| 'stop_sequence' \| 'tool_use' \| 'pause_turn' \| 'refusal'` - -### `TextBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `citations` | 是 | `Array \| null` | -| `text` | 是 | `string` | -| `type` | 是 | `'text'` | - -### `TextBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `text` | 是 | `string` | -| `type` | 是 | `'text'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `citations` | 否 | `Array \| null` | - -### `TextCitation` - -类型别名:`\| CitationCharLocation \| CitationPageLocation \| CitationContentBlockLocation \| CitationsWebSearchResultLocation \| CitationsSearchResultLocation` - -### `TextCitationParam` - -类型别名:`\| CitationCharLocationParam \| CitationPageLocationParam \| CitationContentBlockLocationParam \| CitationWebSearchResultLocationParam \| CitationSearchResultLocationParam` - -### `TextDelta` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `text` | 是 | `string` | -| `type` | 是 | `'text_delta'` | - -### `TextEditorCodeExecutionCreateResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `is_file_update` | 是 | `boolean` | -| `type` | 是 | `'text_editor_code_execution_create_result'` | - -### `TextEditorCodeExecutionCreateResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `is_file_update` | 是 | `boolean` | -| `type` | 是 | `'text_editor_code_execution_create_result'` | - -### `TextEditorCodeExecutionStrReplaceResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `lines` | 是 | `Array \| null` | -| `new_lines` | 是 | `number \| null` | -| `new_start` | 是 | `number \| null` | -| `old_lines` | 是 | `number \| null` | -| `old_start` | 是 | `number \| null` | -| `type` | 是 | `'text_editor_code_execution_str_replace_result'` | - -### `TextEditorCodeExecutionStrReplaceResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'text_editor_code_execution_str_replace_result'` | -| `lines` | 否 | `Array \| null` | -| `new_lines` | 否 | `number \| null` | -| `new_start` | 否 | `number \| null` | -| `old_lines` | 否 | `number \| null` | -| `old_start` | 否 | `number \| null` | - -### `TextEditorCodeExecutionToolResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `\| TextEditorCodeExecutionToolResultError \| TextEditorCodeExecutionViewResultBlock \| TextEditorCodeExecutionCreateResultBlock \| TextEditorCodeExecutionStrReplaceResultBlock` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'text_editor_code_execution_tool_result'` | - -### `TextEditorCodeExecutionToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `\| TextEditorCodeExecutionToolResultErrorParam \| TextEditorCodeExecutionViewResultBlockParam \| TextEditorCodeExecutionCreateResultBlockParam \| TextEditorCodeExecutionStrReplaceResultBlockParam` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'text_editor_code_execution_tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `TextEditorCodeExecutionToolResultError` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `TextEditorCodeExecutionToolResultErrorCode` | -| `error_message` | 是 | `string \| null` | -| `type` | 是 | `'text_editor_code_execution_tool_result_error'` | - -### `TextEditorCodeExecutionToolResultErrorCode` - -类型别名:`\| 'invalid_tool_input' \| 'unavailable' \| 'too_many_requests' \| 'execution_time_exceeded' \| 'file_not_found'` - -### `TextEditorCodeExecutionToolResultErrorParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `TextEditorCodeExecutionToolResultErrorCode` | -| `type` | 是 | `'text_editor_code_execution_tool_result_error'` | -| `error_message` | 否 | `string \| null` | - -### `TextEditorCodeExecutionViewResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `string` | -| `file_type` | 是 | `'text' \| 'image' \| 'pdf'` | -| `num_lines` | 是 | `number \| null` | -| `start_line` | 是 | `number \| null` | -| `total_lines` | 是 | `number \| null` | -| `type` | 是 | `'text_editor_code_execution_view_result'` | - -### `TextEditorCodeExecutionViewResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `string` | -| `file_type` | 是 | `'text' \| 'image' \| 'pdf'` | -| `type` | 是 | `'text_editor_code_execution_view_result'` | -| `num_lines` | 否 | `number \| null` | -| `start_line` | 否 | `number \| null` | -| `total_lines` | 否 | `number \| null` | - -### `ThinkingBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `signature` | 是 | `string` | -| `thinking` | 是 | `string` | -| `type` | 是 | `'thinking'` | - -### `ThinkingBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `signature` | 是 | `string` | -| `thinking` | 是 | `string` | -| `type` | 是 | `'thinking'` | - -### `ThinkingConfigAdaptive` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'adaptive'` | -| `display` | 否 | `'summarized' \| 'omitted' \| null` | - -### `ThinkingConfigDisabled` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'disabled'` | - -### `ThinkingConfigEnabled` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `budget_tokens` | 是 | `number` | -| `type` | 是 | `'enabled'` | -| `display` | 否 | `'summarized' \| 'omitted' \| null` | - -### `ThinkingConfigParam` - -类型别名:`ThinkingConfigEnabled \| ThinkingConfigDisabled \| ThinkingConfigAdaptive` - -### `ThinkingDelta` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `thinking` | 是 | `string` | -| `type` | 是 | `'thinking_delta'` | - -### `Tool` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `input_schema` | 是 | `Tool.InputSchema` | -| `name` | 是 | `string` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `description` | 否 | `string` | -| `eager_input_streaming` | 否 | `boolean \| null` | -| `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | -| `strict` | 否 | `boolean` | -| `type` | 否 | `'custom' \| null` | - -### `ToolBash20250124` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'bash'` | -| `type` | 是 | `'bash_20250124'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | -| `strict` | 否 | `boolean` | - -### `ToolChoice` - -类型别名:`ToolChoiceAuto \| ToolChoiceAny \| ToolChoiceTool \| ToolChoiceNone` - -### `ToolChoiceAny` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'any'` | -| `disable_parallel_tool_use` | 否 | `boolean` | - -### `ToolChoiceAuto` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'auto'` | -| `disable_parallel_tool_use` | 否 | `boolean` | - -### `ToolChoiceNone` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'none'` | - -### `ToolChoiceTool` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `string` | -| `type` | 是 | `'tool'` | -| `disable_parallel_tool_use` | 否 | `boolean` | - -### `ToolReferenceBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_name` | 是 | `string` | -| `type` | 是 | `'tool_reference'` | - -### `ToolReferenceBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_name` | 是 | `string` | -| `type` | 是 | `'tool_reference'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `ToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `content` | 否 | `\| string \| Array< \| TextBlockParam \| ImageBlockParam \| SearchResultBlockParam \| DocumentBlockParam \| ToolReferenceBlockParam >` | -| `is_error` | 否 | `boolean` | - -### `ToolSearchToolBm25_20251119` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'tool_search_tool_bm25'` | -| `type` | 是 | `'tool_search_tool_bm25_20251119' \| 'tool_search_tool_bm25'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `strict` | 否 | `boolean` | - -### `ToolSearchToolRegex20251119` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'tool_search_tool_regex'` | -| `type` | 是 | `'tool_search_tool_regex_20251119' \| 'tool_search_tool_regex'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `strict` | 否 | `boolean` | - -### `ToolSearchToolResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `ToolSearchToolResultError \| ToolSearchToolSearchResultBlock` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'tool_search_tool_result'` | - -### `ToolSearchToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `ToolSearchToolResultErrorParam \| ToolSearchToolSearchResultBlockParam` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'tool_search_tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | - -### `ToolSearchToolResultError` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `ToolSearchToolResultErrorCode` | -| `error_message` | 是 | `string \| null` | -| `type` | 是 | `'tool_search_tool_result_error'` | - -### `ToolSearchToolResultErrorCode` - -类型别名:`\| 'invalid_tool_input' \| 'unavailable' \| 'too_many_requests' \| 'execution_time_exceeded'` - -### `ToolSearchToolResultErrorParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `ToolSearchToolResultErrorCode` | -| `type` | 是 | `'tool_search_tool_result_error'` | - -### `ToolSearchToolSearchResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_references` | 是 | `Array` | -| `type` | 是 | `'tool_search_tool_search_result'` | - -### `ToolSearchToolSearchResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `tool_references` | 是 | `Array` | -| `type` | 是 | `'tool_search_tool_search_result'` | - -### `ToolTextEditor20250124` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'str_replace_editor'` | -| `type` | 是 | `'text_editor_20250124'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | -| `strict` | 否 | `boolean` | - -### `ToolTextEditor20250429` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'str_replace_based_edit_tool'` | -| `type` | 是 | `'text_editor_20250429'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | -| `strict` | 否 | `boolean` | - -### `ToolTextEditor20250728` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'str_replace_based_edit_tool'` | -| `type` | 是 | `'text_editor_20250728'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `input_examples` | 否 | `Array<{ [key: string]: unknown }>` | -| `max_characters` | 否 | `number \| null` | -| `strict` | 否 | `boolean` | - -### `ToolUnion` - -类型别名:`\| Tool \| ToolBash20250124 \| CodeExecutionTool20250522 \| CodeExecutionTool20250825 \| CodeExecutionTool20260120 \| MemoryTool20250818 \| ToolTextEditor20250124 \| ToolTextEditor20250429 \| ToolTextEditor20250728 \| WebSearchTool20250305 \| WebFetchTool20250910 \| WebSearchTool20260209 \| WebFetchTool20260209 \| WebFetchTool20260309 \| ToolSearchToolBm25_20251119 \| ToolSearchToolRegex20251119` - -### `ToolUseBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `id` | 是 | `string` | -| `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | -| `input` | 是 | `unknown` | -| `name` | 是 | `string` | -| `type` | 是 | `'tool_use'` | - -### `ToolUseBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `id` | 是 | `string` | -| `input` | 是 | `unknown` | -| `name` | 是 | `string` | -| `type` | 是 | `'tool_use'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | - -### `URLImageSource` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'url'` | -| `url` | 是 | `string` | - -### `URLPDFSource` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'url'` | -| `url` | 是 | `string` | - -### `Usage` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `cache_creation` | 是 | `CacheCreation \| null` | -| `cache_creation_input_tokens` | 是 | `number \| null` | -| `cache_read_input_tokens` | 是 | `number \| null` | -| `inference_geo` | 是 | `string \| null` | -| `input_tokens` | 是 | `number` | -| `output_tokens` | 是 | `number` | -| `output_tokens_details` | 是 | `OutputTokensDetails \| null` | -| `server_tool_use` | 是 | `ServerToolUsage \| null` | -| `service_tier` | 是 | `'standard' \| 'priority' \| 'batch' \| null` | - -### `UserLocation` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `type` | 是 | `'approximate'` | -| `city` | 否 | `string \| null` | -| `country` | 否 | `string \| null` | -| `region` | 否 | `string \| null` | -| `timezone` | 否 | `string \| null` | - -### `WebFetchBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `DocumentBlock` | -| `retrieved_at` | 是 | `string \| null` | -| `type` | 是 | `'web_fetch_result'` | -| `url` | 是 | `string` | - -### `WebFetchBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `DocumentBlockParam` | -| `type` | 是 | `'web_fetch_result'` | -| `url` | 是 | `string` | -| `retrieved_at` | 否 | `string \| null` | - -### `WebFetchTool20250910` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'web_fetch'` | -| `type` | 是 | `'web_fetch_20250910'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `allowed_domains` | 否 | `Array \| null` | -| `blocked_domains` | 否 | `Array \| null` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `citations` | 否 | `CitationsConfigParam \| null` | -| `defer_loading` | 否 | `boolean` | -| `max_content_tokens` | 否 | `number \| null` | -| `max_uses` | 否 | `number \| null` | -| `strict` | 否 | `boolean` | - -### `WebFetchTool20260209` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'web_fetch'` | -| `type` | 是 | `'web_fetch_20260209'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `allowed_domains` | 否 | `Array \| null` | -| `blocked_domains` | 否 | `Array \| null` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `citations` | 否 | `CitationsConfigParam \| null` | -| `defer_loading` | 否 | `boolean` | -| `max_content_tokens` | 否 | `number \| null` | -| `max_uses` | 否 | `number \| null` | -| `strict` | 否 | `boolean` | - -### `WebFetchTool20260309` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'web_fetch'` | -| `type` | 是 | `'web_fetch_20260309'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `allowed_domains` | 否 | `Array \| null` | -| `blocked_domains` | 否 | `Array \| null` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `citations` | 否 | `CitationsConfigParam \| null` | -| `defer_loading` | 否 | `boolean` | -| `max_content_tokens` | 否 | `number \| null` | -| `max_uses` | 否 | `number \| null` | -| `strict` | 否 | `boolean` | -| `use_cache` | 否 | `boolean` | - -### `WebFetchToolResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | -| `content` | 是 | `WebFetchToolResultErrorBlock \| WebFetchBlock` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'web_fetch_tool_result'` | - -### `WebFetchToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `WebFetchToolResultErrorBlockParam \| WebFetchBlockParam` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'web_fetch_tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | - -### `WebFetchToolResultErrorBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `WebFetchToolResultErrorCode` | -| `type` | 是 | `'web_fetch_tool_result_error'` | - -### `WebFetchToolResultErrorBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `WebFetchToolResultErrorCode` | -| `type` | 是 | `'web_fetch_tool_result_error'` | - -### `WebFetchToolResultErrorCode` - -类型别名:`\| 'invalid_tool_input' \| 'url_too_long' \| 'url_not_allowed' \| 'url_not_in_prior_context' \| 'url_not_accessible' \| 'unsupported_content_type' \| 'too_many_requests' \| 'max_uses_exceeded' \| 'unavailable'` - -### `WebSearchResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `encrypted_content` | 是 | `string` | -| `page_age` | 是 | `string \| null` | -| `title` | 是 | `string` | -| `type` | 是 | `'web_search_result'` | -| `url` | 是 | `string` | - -### `WebSearchResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `encrypted_content` | 是 | `string` | -| `title` | 是 | `string` | -| `type` | 是 | `'web_search_result'` | -| `url` | 是 | `string` | -| `page_age` | 否 | `string \| null` | - -### `WebSearchTool20250305` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'web_search'` | -| `type` | 是 | `'web_search_20250305'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `allowed_domains` | 否 | `Array \| null` | -| `blocked_domains` | 否 | `Array \| null` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `max_uses` | 否 | `number \| null` | -| `strict` | 否 | `boolean` | -| `user_location` | 否 | `UserLocation \| null` | - -### `WebSearchTool20260209` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `name` | 是 | `'web_search'` | -| `type` | 是 | `'web_search_20260209'` | -| `allowed_callers` | 否 | `Array<'direct' \| 'code_execution_20250825' \| 'code_execution_20260120'>` | -| `allowed_domains` | 否 | `Array \| null` | -| `blocked_domains` | 否 | `Array \| null` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `defer_loading` | 否 | `boolean` | -| `max_uses` | 否 | `number \| null` | -| `strict` | 否 | `boolean` | -| `user_location` | 否 | `UserLocation \| null` | - -### `WebSearchToolRequestError` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `WebSearchToolResultErrorCode` | -| `type` | 是 | `'web_search_tool_result_error'` | - -### `WebSearchToolResultBlock` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `caller` | 是 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | -| `content` | 是 | `WebSearchToolResultBlockContent` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'web_search_tool_result'` | - -### `WebSearchToolResultBlockContent` - -类型别名:`WebSearchToolResultError \| Array` - -### `WebSearchToolResultBlockParam` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `content` | 是 | `WebSearchToolResultBlockParamContent` | -| `tool_use_id` | 是 | `string` | -| `type` | 是 | `'web_search_tool_result'` | -| `cache_control` | 否 | `CacheControlEphemeral \| null` | -| `caller` | 否 | `DirectCaller \| ServerToolCaller \| ServerToolCaller20260120` | - -### `WebSearchToolResultBlockParamContent` - -类型别名:`\| Array \| WebSearchToolRequestError` - -### `WebSearchToolResultError` - -| 字段 | 必填 | 类型 | -| --- | --- | --- | -| `error_code` | 是 | `WebSearchToolResultErrorCode` | -| `type` | 是 | `'web_search_tool_result_error'` | - -### `WebSearchToolResultErrorCode` - -类型别名:`\| 'invalid_tool_input' \| 'unavailable' \| 'max_uses_exceeded' \| 'too_many_requests' \| 'query_too_long' \| 'request_too_large'` - -## Gemini Endpoints - -| Method | Path | Request schema | Response schema | Aether format | -| --- | --- | --- | --- | --- | -| POST | `v1beta/{+model}:generateContent` | `GenerateContentRequest` | `GenerateContentResponse` | `gemini:generate_content` | -| POST | `v1beta/{+model}:streamGenerateContent` | `GenerateContentRequest` | `GenerateContentResponse (SSE)` | `gemini:generate_content` | -| POST | `v1beta/{+model}:embedContent` | `EmbedContentRequest` | `EmbedContentResponse` | `gemini:embedding` | -| POST | `v1beta/{+model}:batchEmbedContents` | `BatchEmbedContentsRequest` | `BatchEmbedContentsResponse` | `gemini:embedding` | -| POST | `v1beta/{+model}:countTokens` | `CountTokensRequest` | `CountTokensResponse` | `gemini:generate_content` | -| POST | `v1beta/files` | `CreateFileRequest` | `CreateFileResponse` | `gemini files` | -| GET | `v1beta/files` | - | `ListFilesResponse` | `gemini files` | -| GET | `v1beta/{+name}` | - | `File` | `gemini files` | -| DELETE | `v1beta/{+name}` | - | `Empty` | `gemini files` | -| POST | `v1beta/{+model}:predictLongRunning` | `PredictLongRunningRequest` | `Operation` | `gemini video` | - -## Gemini Schema 字段表 - -以下 schema 从 Gemini native 接口根 schema 递归引用得到,共 97 个。 - -### `AttributionSourceId` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Identifier for the source contributing to this attribution. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `groundingPassage` | 否 | `GroundingPassageId` | - | Identifier for an inline passage. | -| `semanticRetrieverChunk` | 否 | `SemanticRetrieverChunk` | - | Identifier for a Chunk fetched via Semantic Retriever. | - -### `AudioResponseFormat` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration for audio output format. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `bitRate` | 否 | `integer(int32)` | - | Optional. Bit rate in bits per second (bps). Only applicable for compressed formats (MP3, Opus). | -| `delivery` | 否 | `string` | `DELIVERY_UNSPECIFIED`, `INLINE`, `URI` | Optional. The delivery mode for the audio output. | -| `mimeType` | 否 | `string` | `MIME_TYPE_UNSPECIFIED`, `AUDIO_MP3`, `AUDIO_OGG_OPUS`, `AUDIO_L16`, `AUDIO_WAV`, `AUDIO_ALAW`, `AUDIO_MULAW` | Optional. The MIME type of the audio output. | -| `sampleRate` | 否 | `integer(int32)` | - | Optional. Sample rate in Hz. | - -### `BatchEmbedContentsRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Batch request to get embeddings from the model for a list of prompts. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `requests` | 否 | `array` | - | Required. Embed requests for the batch. The model in each of these requests must match the model specified BatchEmbedContentsRequest.model. | - -### `BatchEmbedContentsResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The response to a BatchEmbedContentsRequest. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `embeddings` | 否 | `array` | - | Output only. The embeddings for each request, in the same order as provided in the batch request. | -| `usageMetadata` | 否 | `EmbeddingUsageMetadata` | - | Output only. The usage metadata for the request. | - -### `Blob` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Raw media bytes. Text should not be sent as raw bytes, use the 'text' field. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `data` | 否 | `string(byte)` | - | Raw bytes for media formats. | -| `mimeType` | 否 | `string` | - | The IANA standard MIME type of the source data. Examples of supported types: - Images: image/png, image/jpeg, image/jpg, image/webp, image/heic, image/heif, image/gif, image/avif … | - -### `Candidate` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A response candidate generated from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `avgLogprobs` | 否 | `number(double)` | - | Output only. Average log probability score of the candidate. | -| `citationMetadata` | 否 | `CitationMetadata` | - | Output only. Citation information for model-generated candidate. This field may be populated with recitation information for any text included in the content. These are passages t… | -| `content` | 否 | `Content` | - | Output only. Generated content returned from the model. | -| `finishMessage` | 否 | `string` | - | Optional. Output only. Details the reason why the model stopped generating tokens. This is populated only when finish_reason is set. | -| `finishReason` | 否 | `string` | `FINISH_REASON_UNSPECIFIED`, `STOP`, `MAX_TOKENS`, `SAFETY`, `RECITATION`, `LANGUAGE`, `OTHER`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `SPII`, `MALFORMED_FUNCTION_CALL`, `IMAGE_SAFETY`, `IMAGE_PROHIBITED_CONTENT`, `IMAGE_OTHER`, `NO_IMAGE`, `IMAGE_RECITATION`, `UNEXPECTED_TOOL_CALL`, `TOO_MANY_TOOL_CALLS`, `MISSING_THOUGHT_SIGNATURE`, `MALFORMED_RESPONSE`, `ESCALATION` | Optional. Output only. The reason why the model stopped generating tokens. If empty, the model has not stopped generating tokens. | -| `groundingAttributions` | 否 | `array` | - | Output only. Attribution information for sources that contributed to a grounded answer. This field is populated for GenerateAnswer calls. | -| `groundingMetadata` | 否 | `GroundingMetadata` | - | Output only. Grounding metadata for the candidate. This field is populated for GenerateContent calls. | -| `index` | 否 | `integer(int32)` | - | Output only. Index of the candidate in the list of response candidates. | -| `logprobsResult` | 否 | `LogprobsResult` | - | Output only. Log-likelihood scores for the response tokens and top tokens | -| `safetyRatings` | 否 | `array` | - | List of ratings for the safety of a response candidate. There is at most one rating per category. | -| `tokenCount` | 否 | `integer(int32)` | - | Output only. Token count for this candidate. | -| `urlContextMetadata` | 否 | `UrlContextMetadata` | - | Output only. Metadata related to url context retrieval tool. | - -### `CitationMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A collection of source attributions for a piece of content. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `citationSources` | 否 | `array` | - | Citations to sources for a specific response. | - -### `CitationSource` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A citation to a source for a portion of a specific response. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `endIndex` | 否 | `integer(int32)` | - | Optional. End of the attributed segment, exclusive. | -| `license` | 否 | `string` | - | Optional. License for the GitHub project that is attributed as a source for segment. License info is required for code citations. | -| `startIndex` | 否 | `integer(int32)` | - | Optional. Start of segment of the response that is attributed to this source. Index indicates the start of the segment, measured in bytes. | -| `uri` | 否 | `string` | - | Optional. URI that is attributed as a source for a portion of the text. | - -### `CodeExecution` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Tool that executes code generated by the model, and automatically returns the result to the model. See also ExecutableCode and CodeExecutionResult which are only generated when us… | - -### `CodeExecutionResult` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Result of executing the ExecutableCode. Generated only when the CodeExecution tool is used. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 否 | `string` | - | Optional. The identifier of the ExecutableCode part this result is for. Only populated if the corresponding ExecutableCode has an id. | -| `outcome` | 否 | `string` | `OUTCOME_UNSPECIFIED`, `OUTCOME_OK`, `OUTCOME_FAILED`, `OUTCOME_DEADLINE_EXCEEDED` | Required. Outcome of the code execution. | -| `output` | 否 | `string` | - | Optional. Contains stdout when code execution is successful, stderr or other description otherwise. | - -### `ComputerUse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Computer Use tool type. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `environment` | 否 | `string` | `ENVIRONMENT_UNSPECIFIED`, `ENVIRONMENT_BROWSER` | Required. The environment being operated. | -| `excludedPredefinedFunctions` | 否 | `array` | - | Optional. By default, predefined functions are included in the final model call. Some of them can be explicitly excluded from being automatically included. This can serve two purp… | - -### `Content` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The base structured datatype containing multi-part content of a message. A Content includes a role field designating the producer of the Content and a parts field containing multi… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `parts` | 否 | `array` | - | Ordered Parts that constitute a single message. Parts may have different MIME types. | -| `role` | 否 | `string` | - | Optional. The producer of the content. Must be either 'user' or 'model'. Useful to set for multi-turn conversations, otherwise can be left blank or unset. | - -### `ContentEmbedding` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A list of floats representing an embedding. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `shape` | 否 | `array` | - | This field stores the soft tokens tensor frame shape (e.g. [1, 1, 256, 2048]). | -| `values` | 否 | `array` | - | The embedding values. This is for 3P users only and will not be populated for 1P calls. | - -### `CountTokensRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Counts the number of tokens in the prompt sent to a model. Models may tokenize text differently, so each model may return a different token_count. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `contents` | 否 | `array` | - | Optional. The input given to the model as a prompt. This field is ignored when generate_content_request is set. | -| `generateContentRequest` | 否 | `GenerateContentRequest` | - | Optional. The overall input given to the Model. This includes the prompt as well as other model steering information like [system instructions](https://ai.google.dev/gemini-api/do… | - -### `CountTokensResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A response from CountTokens. It returns the model's token_count for the prompt. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `cacheTokensDetails` | 否 | `array` | - | Output only. List of modalities that were processed in the cached content. | -| `cachedContentTokenCount` | 否 | `integer(int32)` | - | Number of tokens in the cached part of the prompt (the cached content). | -| `promptTokensDetails` | 否 | `array` | - | Output only. List of modalities that were processed in the request input. | -| `totalTokens` | 否 | `integer(int32)` | - | The number of tokens that the Model tokenizes the prompt into. Always non-negative. | - -### `CreateFileRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Request for CreateFile. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file` | 否 | `File` | - | Optional. Metadata for the file to create. | - -### `CreateFileResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Response for CreateFile. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `file` | 否 | `File` | - | Metadata for the created file. | - -### `DynamicRetrievalConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Describes the options to customize dynamic retrieval. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `dynamicThreshold` | 否 | `number(float)` | - | The threshold to be used in dynamic retrieval. If not set, a system default value is used. | -| `mode` | 否 | `string` | `MODE_UNSPECIFIED`, `MODE_DYNAMIC` | The mode of the predictor to be used in dynamic retrieval. | - -### `EmbedContentConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configurations for the EmbedContent request. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `audioTrackExtraction` | 否 | `boolean` | - | Optional. Whether to extract audio from video content. | -| `autoTruncate` | 否 | `boolean` | - | Optional. Whether to silently truncate the input content if it's longer than the maximum sequence length. | -| `documentOcr` | 否 | `boolean` | - | Optional. Whether to enable OCR for document content. | -| `outputDimensionality` | 否 | `integer(int32)` | - | Optional. Reduced dimension for the output embedding. If set, excessive values in the output embedding are truncated from the end. | -| `taskType` | 否 | `string` | `TASK_TYPE_UNSPECIFIED`, `RETRIEVAL_QUERY`, `RETRIEVAL_DOCUMENT`, `SEMANTIC_SIMILARITY`, `CLASSIFICATION`, `CLUSTERING`, `QUESTION_ANSWERING`, `FACT_VERIFICATION`, `CODE_RETRIEVAL_QUERY` | Optional. The task type of the embedding. | -| `title` | 否 | `string` | - | Optional. The title for the text. | - -### `EmbedContentRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Request containing the Content for the model to embed. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 否 | `Content` | - | Required. The content to embed. Only the parts.text fields will be counted. | -| `embedContentConfig` | 否 | `EmbedContentConfig` | - | Optional. Configuration for the EmbedContent request. | -| `model` | 否 | `string` | - | Required. The model's resource name. This serves as an ID for the Model to use. This name should match a model name returned by the ListModels method. Format: models/{model} | -| `outputDimensionality` | 否 | `integer(int32)` | - | Optional. Deprecated: Please use EmbedContentConfig.output_dimensionality instead. Optional reduced dimension for the output embedding. If set, excessive values in the output embe… | -| `taskType` | 否 | `string` | `TASK_TYPE_UNSPECIFIED`, `RETRIEVAL_QUERY`, `RETRIEVAL_DOCUMENT`, `SEMANTIC_SIMILARITY`, `CLASSIFICATION`, `CLUSTERING`, `QUESTION_ANSWERING`, `FACT_VERIFICATION`, `CODE_RETRIEVAL_QUERY` | Optional. Deprecated: Please use EmbedContentConfig.task_type instead. Optional task type for which the embeddings will be used. Not supported on earlier models (models/embedding-… | -| `title` | 否 | `string` | - | Optional. Deprecated: Please use EmbedContentConfig.title instead. An optional title for the text. Only applicable when TaskType is RETRIEVAL_DOCUMENT. Note: Specifying a title fo… | - -### `EmbedContentResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The response to an EmbedContentRequest. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `embedding` | 否 | `ContentEmbedding` | - | Output only. The embedding generated from the input content. | -| `usageMetadata` | 否 | `EmbeddingUsageMetadata` | - | Output only. The usage metadata for the request. | - -### `EmbeddingUsageMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Metadata on the usage of the embedding request. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `promptTokenCount` | 否 | `integer(int32)` | - | Output only. Number of tokens in the prompt. | -| `promptTokenDetails` | 否 | `array` | - | Output only. List of modalities that were processed in the request input. | - -### `ExecutableCode` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Code generated by the model that is meant to be executed, and the result returned to the model. Only generated when using the CodeExecution tool, in which the code will be automat… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `code` | 否 | `string` | - | Required. The code to be executed. | -| `id` | 否 | `string` | - | Optional. Unique identifier of the ExecutableCode part. The server returns the CodeExecutionResult with the matching id. | -| `language` | 否 | `string` | `LANGUAGE_UNSPECIFIED`, `PYTHON` | Required. Programming language of the code. | - -### `File` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A file uploaded to the API. Next ID: 15 | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `createTime` | 否 | `string(google-datetime)` | - | Output only. The timestamp of when the File was created. | -| `displayName` | 否 | `string` | - | Optional. The human-readable display name for the File. The display name must be no more than 512 characters in length, including spaces. Example: "Welcome Image" | -| `downloadUri` | 否 | `string` | - | Output only. The download uri of the File. | -| `error` | 否 | `Status` | - | Output only. Error status if File processing failed. | -| `expirationTime` | 否 | `string(google-datetime)` | - | Output only. The timestamp of when the File will be deleted. Only set if the File is scheduled to expire. | -| `mimeType` | 否 | `string` | - | Output only. MIME type of the file. | -| `name` | 否 | `string` | - | Immutable. Identifier. The File resource name. The ID (name excluding the "files/" prefix) can contain up to 40 characters that are lowercase alphanumeric or dashes (-). The ID ca… | -| `sha256Hash` | 否 | `string(byte)` | - | Output only. SHA-256 hash of the uploaded bytes. | -| `sizeBytes` | 否 | `string(int64)` | - | Output only. Size of the file in bytes. | -| `source` | 否 | `string` | `SOURCE_UNSPECIFIED`, `UPLOADED`, `GENERATED`, `REGISTERED` | Source of the File. | -| `state` | 否 | `string` | `STATE_UNSPECIFIED`, `PROCESSING`, `ACTIVE`, `FAILED` | Output only. Processing state of the File. | -| `updateTime` | 否 | `string(google-datetime)` | - | Output only. The timestamp of when the File was last updated. | -| `uri` | 否 | `string` | - | Output only. The uri of the File. | -| `videoMetadata` | 否 | `VideoFileMetadata` | - | Output only. Metadata for a video. | - -### `FileData` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | URI based data. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `fileUri` | 否 | `string` | - | Required. URI. | -| `mimeType` | 否 | `string` | - | Optional. The IANA standard MIME type of the source data. | - -### `FileSearch` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The FileSearch tool that retrieves knowledge from Semantic Retrieval corpora. Files are imported to Semantic Retrieval corpora using the ImportFile API. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `fileSearchStoreNames` | 否 | `array` | - | Required. The names of the file_search_stores to retrieve from. Example: fileSearchStores/my-file-search-store-123 | -| `metadataFilter` | 否 | `string` | - | Optional. Metadata filter to apply to the semantic retrieval documents and chunks. | -| `topK` | 否 | `integer(int32)` | - | Optional. The number of semantic retrieval chunks to retrieve. | - -### `FunctionCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A predicted FunctionCall returned from the model that contains a string representing the FunctionDeclaration.name with the arguments and their values. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `args` | 否 | `object/map` | - | Optional. The function parameters and values in JSON object format. | -| `id` | 否 | `string` | - | Optional. Unique identifier of the function call. If populated, the client to execute the function_call and return the response with the matching id. | -| `name` | 否 | `string` | - | Required. The name of the function to call. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 128. | - -### `FunctionCallingConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration for specifying function calling behavior. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `allowedFunctionNames` | 否 | `array` | - | Optional. A set of function names that, when provided, limits the functions the model will call. This should only be set when the Mode is ANY or VALIDATED. Function names should m… | -| `mode` | 否 | `string` | `MODE_UNSPECIFIED`, `AUTO`, `ANY`, `NONE`, `VALIDATED` | Optional. Specifies the mode in which function calling should execute. If unspecified, the default value will be set to AUTO. | - -### `FunctionDeclaration` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Structured representation of a function declaration as defined by the [OpenAPI 3.03 specification](https://spec.openapis.org/oas/v3.0.3). Included in this declaration are the func… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `behavior` | 否 | `string` | `UNSPECIFIED`, `BLOCKING`, `NON_BLOCKING` | Optional. Specifies the function Behavior. Currently only supported by the BidiGenerateContent method. | -| `description` | 否 | `string` | - | Required. A brief description of the function. | -| `name` | 否 | `string` | - | Required. The name of the function. Must be a-z, A-Z, 0-9, or contain underscores, colons, dots, and dashes, with a maximum length of 128. | -| `parameters` | 否 | `Schema` | - | Optional. Describes the parameters to this function. Reflects the Open API 3.03 Parameter Object string Key: the name of the parameter. Parameter names are case sensitive. Schema … | -| `parametersJsonSchema` | 否 | `any` | - | Optional. Describes the parameters to the function in JSON Schema format. The schema must describe an object where the properties are the parameters to the function. For example: … | -| `response` | 否 | `Schema` | - | Optional. Describes the output from this function in JSON Schema format. Reflects the Open API 3.03 Response Object. The Schema defines the type used for the response value of the… | -| `responseJsonSchema` | 否 | `any` | - | Optional. Describes the output from this function in JSON Schema format. The value specified by the schema is the response value of the function. This field is mutually exclusive … | - -### `FunctionResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The result output from a FunctionCall that contains a string representing the FunctionDeclaration.name and a structured JSON object containing any output from the function is used… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 否 | `string` | - | Optional. The identifier of the function call this response is for. Populated by the client to match the corresponding function call id. | -| `name` | 否 | `string` | - | Required. The name of the function to call. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 128. | -| `parts` | 否 | `array` | - | Optional. Ordered Parts that constitute a function response. Parts may have different IANA MIME types. | -| `response` | 否 | `object/map` | - | Required. The function response in JSON object format. Callers can use any keys of their choice that fit the function's syntax to return the function output, e.g. "output", "resul… | -| `scheduling` | 否 | `string` | `SCHEDULING_UNSPECIFIED`, `SILENT`, `WHEN_IDLE`, `INTERRUPT` | Optional. Specifies how the response should be scheduled in the conversation. Only applicable to NON_BLOCKING function calls, is ignored otherwise. Defaults to WHEN_IDLE. | -| `willContinue` | 否 | `boolean` | - | Optional. Signals that function call continues, and more responses will be returned, turning the function call into a generator. Is only applicable to NON_BLOCKING function calls,… | - -### `FunctionResponseBlob` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Raw media bytes for function response. Text should not be sent as raw bytes, use the 'FunctionResponse.response' field. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `data` | 否 | `string(byte)` | - | Raw bytes for media formats. | -| `mimeType` | 否 | `string` | - | The IANA standard MIME type of the source data. Examples: - image/png - image/jpeg If an unsupported MIME type is provided, an error will be returned. For a complete list of suppo… | - -### `FunctionResponsePart` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A datatype containing media that is part of a FunctionResponse message. A FunctionResponsePart consists of data which has an associated datatype. A FunctionResponsePart can only c… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `inlineData` | 否 | `FunctionResponseBlob` | - | Inline media bytes. | - -### `GenerateContentRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Request to generate a completion from the model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `cachedContent` | 否 | `string` | - | Optional. The name of the content [cached](https://ai.google.dev/gemini-api/docs/caching) to use as context to serve the prediction. Format: cachedContents/{cachedContent} | -| `contents` | 否 | `array` | - | Required. The content of the current conversation with the model. For single-turn queries, this is a single instance. For multi-turn queries like [chat](https://ai.google.dev/gemi… | -| `generationConfig` | 否 | `GenerationConfig` | - | Optional. Configuration options for model generation and outputs. | -| `model` | 否 | `string` | - | Required. The name of the Model to use for generating the completion. Format: models/{model}. | -| `safetySettings` | 否 | `array` | - | Optional. A list of unique SafetySetting instances for blocking unsafe content. This will be enforced on the GenerateContentRequest.contents and GenerateContentResponse.candidates… | -| `serviceTier` | 否 | `string` | `unspecified`, `standard`, `flex`, `priority` | Optional. The service tier of the request. | -| `store` | 否 | `boolean` | - | Optional. Configures the logging behavior for a given request. If set, it takes precedence over the project-level logging config. | -| `systemInstruction` | 否 | `Content` | - | Optional. Developer set [system instruction(s)](https://ai.google.dev/gemini-api/docs/system-instructions). Currently, text only. | -| `toolConfig` | 否 | `ToolConfig` | - | Optional. Tool configuration for any Tool specified in the request. Refer to the [Function calling guide](https://ai.google.dev/gemini-api/docs/function-calling#function_calling_m… | -| `tools` | 否 | `array` | - | Optional. A list of Tools the Model may use to generate the next response. A Tool is a piece of code that enables the system to interact with external systems to perform an action… | - -### `GenerateContentResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Response from the model supporting multiple candidate responses. Safety ratings and content filtering are reported for both prompt in GenerateContentResponse.prompt_feedback and f… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `candidates` | 否 | `array` | - | Candidate responses from the model. | -| `modelStatus` | 否 | `ModelStatus` | - | Output only. The current model status of this model. | -| `modelVersion` | 否 | `string` | - | Output only. The model version used to generate the response. | -| `promptFeedback` | 否 | `PromptFeedback` | - | Returns the prompt's feedback related to the content filters. | -| `responseId` | 否 | `string` | - | Output only. response_id is used to identify each response. | -| `usageMetadata` | 否 | `UsageMetadata` | - | Output only. Metadata on the generation requests' token usage. | - -### `GenerationConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration options for model generation and outputs. Not all parameters are configurable for every model. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `_responseJsonSchema` | 否 | `any` | - | Optional. Output schema of the generated response. This is an alternative to response_schema that accepts [JSON Schema](https://json-schema.org/). If set, response_schema must be … | -| `candidateCount` | 否 | `integer(int32)` | - | Optional. Number of generated responses to return. If unset, this will default to 1. Please note that this doesn't work for previous generation models (Gemini 1.0 family) | -| `enableEnhancedCivicAnswers` | 否 | `boolean` | - | Optional. Enables enhanced civic answers. It may not be available for all models. | -| `frequencyPenalty` | 否 | `number(float)` | - | Optional. Frequency penalty applied to the next token's logprobs, multiplied by the number of times each token has been seen in the respponse so far. A positive penalty will disco… | -| `imageConfig` | 否 | `ImageConfig` | - | Optional. Config for image generation. An error will be returned if this field is set for models that don't support these config options. | -| `logprobs` | 否 | `integer(int32)` | - | Optional. Only valid if response_logprobs=True. This sets the number of top logprobs, including the chosen candidate, to return at each decoding step in the Candidate.logprobs_res… | -| `maxOutputTokens` | 否 | `integer(int32)` | - | Optional. The maximum number of tokens to include in a response candidate. Note: The default value varies by model, see the Model.output_token_limit attribute of the Model returne… | -| `mediaResolution` | 否 | `string` | `MEDIA_RESOLUTION_UNSPECIFIED`, `MEDIA_RESOLUTION_LOW`, `MEDIA_RESOLUTION_MEDIUM`, `MEDIA_RESOLUTION_HIGH` | Optional. If specified, the media resolution specified will be used. | -| `presencePenalty` | 否 | `number(float)` | - | Optional. Presence penalty applied to the next token's logprobs if the token has already been seen in the response. This penalty is binary on/off and not dependant on the number o… | -| `responseFormat` | 否 | `ResponseFormatConfig` | - | Optional. Configuration for the response output format. Allows specifying output configuration per modality (text, audio, image) in a flat structure. | -| `responseJsonSchema` | 否 | `any` | - | Optional. An internal detail. Use responseJsonSchema rather than this field. | -| `responseLogprobs` | 否 | `boolean` | - | Optional. If true, export the logprobs results in response. | -| `responseMimeType` | 否 | `string` | - | Optional. MIME type of the generated candidate text. Supported MIME types are: text/plain: (default) Text output. application/json: JSON response in the response candidates. text/… | -| `responseModalities` | 否 | `array` | - | Optional. The requested modalities of the response. Represents the set of modalities that the model can return, and should be expected in the response. This is an exact match to t… | -| `responseSchema` | 否 | `Schema` | - | Optional. Output schema of the generated candidate text. Schemas must be a subset of the [OpenAPI schema](https://spec.openapis.org/oas/v3.0.3#schema) and can be objects, primitiv… | -| `seed` | 否 | `integer(int32)` | - | Optional. Seed used in decoding. If not set, the request uses a randomly generated seed. | -| `speechConfig` | 否 | `SpeechConfig` | - | Optional. The speech generation config. | -| `stopSequences` | 否 | `array` | - | Optional. The set of character sequences (up to 5) that will stop output generation. If specified, the API will stop at the first appearance of a stop_sequence. The stop sequence … | -| `temperature` | 否 | `number(float)` | - | Optional. Controls the randomness of the output. Note: The default value varies by model, see the Model.temperature attribute of the Model returned from the getModel function. Val… | -| `thinkingConfig` | 否 | `ThinkingConfig` | - | Optional. Config for thinking features. An error will be returned if this field is set for models that don't support thinking. | -| `topK` | 否 | `integer(int32)` | - | Optional. The maximum number of tokens to consider when sampling. Gemini models use Top-p (nucleus) sampling or a combination of Top-k and nucleus sampling. Top-k sampling conside… | -| `topP` | 否 | `number(float)` | - | Optional. The maximum cumulative probability of tokens to consider when sampling. The model uses combined Top-k and Top-p (nucleus) sampling. Tokens are sorted based on their assi… | - -### `GoogleAiGenerativelanguageV1betaGroundingSupport` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Grounding support. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `confidenceScores` | 否 | `array` | - | Optional. Confidence score of the support references. Ranges from 0 to 1. 1 is the most confident. This list must have the same size as the grounding_chunk_indices. | -| `groundingChunkIndices` | 否 | `array` | - | Optional. A list of indices (into 'grounding_chunk' in response.candidate.grounding_metadata) specifying the citations associated with the claim. For instance [1,3,4] means that g… | -| `renderedParts` | 否 | `array` | - | Output only. Indices into the parts field of the candidate's content. These indices specify which rendered parts are associated with this support source. | -| `segment` | 否 | `GoogleAiGenerativelanguageV1betaSegment` | - | Segment of the content this support belongs to. | - -### `GoogleAiGenerativelanguageV1betaSegment` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Segment of the content. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `endIndex` | 否 | `integer(int32)` | - | End index in the given Part, measured in bytes. Offset from the start of the Part, exclusive, starting at zero. | -| `partIndex` | 否 | `integer(int32)` | - | The index of a Part object within its parent Content object. | -| `startIndex` | 否 | `integer(int32)` | - | Start index in the given Part, measured in bytes. Offset from the start of the Part, inclusive, starting at zero. | -| `text` | 否 | `string` | - | The text corresponding to the segment from the response. | - -### `GoogleMaps` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The GoogleMaps Tool that provides geospatial context for the user's query. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `enableWidget` | 否 | `boolean` | - | Optional. Whether to return a widget context token in the GroundingMetadata of the response. Developers can use the widget context token to render a Google Maps widget with geospa… | - -### `GoogleSearch` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | GoogleSearch tool type. Tool to support Google Search in Model. Powered by Google. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `searchTypes` | 否 | `SearchTypes` | - | Optional. The set of search types to enable. If not set, web search is enabled by default. | -| `timeRangeFilter` | 否 | `Interval` | - | Optional. Filter search results to a specific time range. If customers set a start time, they must set an end time (and vice versa). | - -### `GoogleSearchRetrieval` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Tool to retrieve public web data for grounding, powered by Google. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `dynamicRetrievalConfig` | 否 | `DynamicRetrievalConfig` | - | Specifies the dynamic retrieval configuration for the given source. | - -### `GroundingAttribution` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Attribution for a source that contributed to an answer. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `content` | 否 | `Content` | - | Grounding source content that makes up this attribution. | -| `sourceId` | 否 | `AttributionSourceId` | - | Output only. Identifier for the source contributing to this attribution. | - -### `GroundingChunk` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A GroundingChunk represents a segment of supporting evidence that grounds the model's response. It can be a chunk from the web, a retrieved context from a file, or information fro… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `image` | 否 | `Image` | - | Optional. Grounding chunk from image search. | -| `maps` | 否 | `Maps` | - | Optional. Grounding chunk from Google Maps. | -| `retrievedContext` | 否 | `RetrievedContext` | - | Optional. Grounding chunk from context retrieved by the file search tool. | -| `web` | 否 | `Web` | - | Grounding chunk from the web. | - -### `GroundingChunkCustomMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | User provided metadata about the GroundingFact. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `key` | 否 | `string` | - | The key of the metadata. | -| `numericValue` | 否 | `number(float)` | - | Optional. The numeric value of the metadata. The expected range for this value depends on the specific key used. | -| `stringListValue` | 否 | `GroundingChunkStringList` | - | Optional. A list of string values for the metadata. | -| `stringValue` | 否 | `string` | - | Optional. The string value of the metadata. | - -### `GroundingChunkStringList` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A list of string values. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `values` | 否 | `array` | - | The string values of the list. | - -### `GroundingMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Metadata returned to client when grounding is enabled. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `googleMapsWidgetContextToken` | 否 | `string` | - | Optional. Resource name of the Google Maps widget context token that can be used with the PlacesContextElement widget in order to render contextual data. Only populated in the cas… | -| `groundingChunks` | 否 | `array` | - | List of supporting references retrieved from specified grounding source. When streaming, this only contains the grounding chunks that have not been included in the grounding metad… | -| `groundingSupports` | 否 | `array` | - | List of grounding support. | -| `imageSearchQueries` | 否 | `array` | - | Image search queries used for grounding. | -| `retrievalMetadata` | 否 | `RetrievalMetadata` | - | Metadata related to retrieval in the grounding flow. | -| `searchEntryPoint` | 否 | `SearchEntryPoint` | - | Optional. Google search entry for the following-up web searches. | -| `webSearchQueries` | 否 | `array` | - | Web search queries for the following-up web search. | - -### `GroundingPassageId` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Identifier for a part within a GroundingPassage. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `partIndex` | 否 | `integer(int32)` | - | Output only. Index of the part within the GenerateAnswerRequest's GroundingPassage.content. | -| `passageId` | 否 | `string` | - | Output only. ID of the passage matching the GenerateAnswerRequest's GroundingPassage.id. | - -### `Image` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Chunk from image search. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `domain` | 否 | `string` | - | The root domain of the web page that the image is from, e.g. "example.com". | -| `imageUri` | 否 | `string` | - | The image asset URL. | -| `sourceUri` | 否 | `string` | - | The web page URI for attribution. | -| `title` | 否 | `string` | - | The title of the web page that the image is from. | - -### `ImageConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Config for image generation features. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `aspectRatio` | 否 | `string` | - | Optional. The aspect ratio of the image to generate. Supported aspect ratios: 1:1, 1:4, 4:1, 1:8, 8:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9. If not specified, the mod… | -| `imageSize` | 否 | `string` | - | Optional. Specifies the size of generated images. Supported values are 512, 1K, 2K, 4K. If not specified, the model will use default value 1K. | - -### `ImageResponseFormat` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration for image output format. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `aspectRatio` | 否 | `string` | `ASPECT_RATIO_UNSPECIFIED`, `ASPECT_RATIO_ONE_BY_ONE`, `ASPECT_RATIO_TWO_BY_THREE`, `ASPECT_RATIO_THREE_BY_TWO`, `ASPECT_RATIO_THREE_BY_FOUR`, `ASPECT_RATIO_FOUR_BY_THREE`, `ASPECT_RATIO_FOUR_BY_FIVE`, `ASPECT_RATIO_FIVE_BY_FOUR`, `ASPECT_RATIO_NINE_BY_SIXTEEN`, `ASPECT_RATIO_SIXTEEN_BY_NINE`, `ASPECT_RATIO_TWENTY_ONE_BY_NINE`, `ASPECT_RATIO_ONE_BY_EIGHT`, `ASPECT_RATIO_EIGHT_BY_ONE`, `ASPECT_RATIO_ONE_BY_FOUR`, `ASPECT_RATIO_FOUR_BY_ONE` | Optional. The aspect ratio for the image output. | -| `delivery` | 否 | `string` | `DELIVERY_UNSPECIFIED`, `INLINE`, `URI` | Optional. The delivery mode for the image output. | -| `imageSize` | 否 | `string` | `IMAGE_SIZE_UNSPECIFIED`, `IMAGE_SIZE_FIVE_TWELVE`, `IMAGE_SIZE_ONE_K`, `IMAGE_SIZE_TWO_K`, `IMAGE_SIZE_FOUR_K` | Optional. The size of the image output. | -| `mimeType` | 否 | `string` | `MIME_TYPE_UNSPECIFIED`, `IMAGE_JPEG` | Optional. The MIME type of the image output. | - -### `ImageSearch` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Image search for grounding and related configurations. | - -### `Interval` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents a time interval, encoded as a Timestamp start (inclusive) and a Timestamp end (exclusive). The start must be less than or equal to the end. When the start equals the en… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `endTime` | 否 | `string(google-datetime)` | - | Optional. Exclusive end of the interval. If specified, a Timestamp matching this interval will have to be before the end. | -| `startTime` | 否 | `string(google-datetime)` | - | Optional. Inclusive start of the interval. If specified, a Timestamp matching this interval will have to be the same or after the start. | - -### `LatLng` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | An object that represents a latitude/longitude pair. This is expressed as a pair of doubles to represent degrees latitude and degrees longitude. Unless specified otherwise, this o… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `latitude` | 否 | `number(double)` | - | The latitude in degrees. It must be in the range [-90.0, +90.0]. | -| `longitude` | 否 | `number(double)` | - | The longitude in degrees. It must be in the range [-180.0, +180.0]. | - -### `ListFilesResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Response for ListFiles. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `files` | 否 | `array` | - | The list of Files. | -| `nextPageToken` | 否 | `string` | - | A token that can be sent as a page_token into a subsequent ListFiles call. | - -### `LogprobsResult` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Logprobs Result | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `chosenCandidates` | 否 | `array` | - | Length = total number of decoding steps. The chosen candidates may or may not be in top_candidates. | -| `logProbabilitySum` | 否 | `number(float)` | - | Sum of log probabilities for all tokens. | -| `topCandidates` | 否 | `array` | - | Length = total number of decoding steps. | - -### `LogprobsResultCandidate` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Candidate for the logprobs token and score. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `logProbability` | 否 | `number(float)` | - | The candidate's log probability. | -| `token` | 否 | `string` | - | The candidate’s token string value. | -| `tokenId` | 否 | `integer(int32)` | - | The candidate’s token id value. | - -### `Maps` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A grounding chunk from Google Maps. A Maps chunk corresponds to a single place. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `placeAnswerSources` | 否 | `PlaceAnswerSources` | - | Sources that provide answers about the features of a given place in Google Maps. | -| `placeId` | 否 | `string` | - | The ID of the place, in places/{place_id} format. A user can use this ID to look up that place. | -| `text` | 否 | `string` | - | Text description of the place answer. | -| `title` | 否 | `string` | - | Title of the place. | -| `uri` | 否 | `string` | - | URI reference of the place. | - -### `McpServer` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A MCPServer is a server that can be called by the model to perform actions. It is a server that implements the MCP protocol. Next ID: 6 | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `name` | 否 | `string` | - | The name of the MCPServer. | -| `streamableHttpTransport` | 否 | `StreamableHttpTransport` | - | A transport that can stream HTTP requests and responses. | - -### `ModalityTokenCount` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Represents token counting info for a single modality. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `modality` | 否 | `string` | `MODALITY_UNSPECIFIED`, `TEXT`, `IMAGE`, `VIDEO`, `AUDIO`, `DOCUMENT` | The modality associated with this token count. | -| `tokenCount` | 否 | `integer(int32)` | - | Number of tokens. | - -### `ModelStatus` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The status of the underlying model. This is used to indicate the stage of the underlying model and the retirement time if applicable. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `message` | 否 | `string` | - | A message explaining the model status. | -| `modelStage` | 否 | `string` | `MODEL_STAGE_UNSPECIFIED`, `UNSTABLE_EXPERIMENTAL`, `EXPERIMENTAL`, `PREVIEW`, `STABLE`, `LEGACY`, `DEPRECATED`, `RETIRED` | The stage of the underlying model. | -| `retirementTime` | 否 | `string(google-datetime)` | - | The time at which the model will be retired. | - -### `MultiSpeakerVoiceConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The configuration for the multi-speaker setup. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `speakerVoiceConfigs` | 否 | `array` | - | Required. All the enabled speaker voices. | - -### `Operation` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | This resource represents a long-running operation that is the result of a network API call. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `done` | 否 | `boolean` | - | If the value is false, it means the operation is still in progress. If true, the operation is completed, and either error or response is available. | -| `error` | 否 | `Status` | - | The error result of the operation in case of failure or cancellation. | -| `metadata` | 否 | `object/map` | - | Service-specific metadata associated with the operation. It typically contains progress information and common metadata such as create time. Some services might not provide such m… | -| `name` | 否 | `string` | - | The server-assigned name, which is only unique within the same service that originally returns it. If you use the default HTTP mapping, the name should be a resource name ending w… | -| `response` | 否 | `object/map` | - | The normal, successful response of the operation. If the original method returns no data on success, such as Delete, the response is google.protobuf.Empty. If the original method … | - -### `Part` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A datatype containing media that is part of a multi-part Content message. A Part consists of data which has an associated datatype. A Part can only contain one of the accepted typ… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `codeExecutionResult` | 否 | `CodeExecutionResult` | - | Result of executing the ExecutableCode. | -| `executableCode` | 否 | `ExecutableCode` | - | Code generated by the model that is meant to be executed. | -| `fileData` | 否 | `FileData` | - | URI based data. | -| `functionCall` | 否 | `FunctionCall` | - | A predicted FunctionCall returned from the model that contains a string representing the FunctionDeclaration.name with the arguments and their values. | -| `functionResponse` | 否 | `FunctionResponse` | - | The result output of a FunctionCall that contains a string representing the FunctionDeclaration.name and a structured JSON object containing any output from the function is used a… | -| `inlineData` | 否 | `Blob` | - | Inline media bytes. | -| `mediaResolution` | 否 | `MediaResolution` | - | Optional. Media resolution for the input media. | -| `partMetadata` | 否 | `object/map` | - | Custom metadata associated with the Part. Agents using genai.Part as content representation may need to keep track of the additional information. For example it can be name of a f… | -| `text` | 否 | `string` | - | Inline text. | -| `thought` | 否 | `boolean` | - | Optional. Indicates if the part is thought from the model. | -| `thoughtSignature` | 否 | `string(byte)` | - | Optional. An opaque signature for the thought so it can be reused in subsequent requests. | -| `toolCall` | 否 | `ToolCall` | - | Server-side tool call. This field is populated when the model predicts a tool invocation that should be executed on the server. The client is expected to echo this message back to… | -| `toolResponse` | 否 | `ToolResponse` | - | The output from a server-side ToolCall execution. This field is populated by the client with the results of executing the corresponding ToolCall. | -| `videoMetadata` | 否 | `VideoMetadata` | - | Optional. Video metadata. The metadata should only be specified while the video data is presented in inline_data or file_data. | - -### `PlaceAnswerSources` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Collection of sources that provide answers about the features of a given place in Google Maps. Each PlaceAnswerSources message corresponds to a specific place in Google Maps. The … | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `reviewSnippets` | 否 | `array` | - | Snippets of reviews that are used to generate answers about the features of a given place in Google Maps. | - -### `PrebuiltVoiceConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The configuration for the prebuilt speaker to use. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `voiceName` | 否 | `string` | - | The name of the preset voice to use. | - -### `PredictLongRunningRequest` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Request message for [PredictionService.PredictLongRunning]. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `instances` | 否 | `array` | - | Required. The instances that are the input to the prediction call. | -| `parameters` | 否 | `any` | - | Optional. The parameters that govern the prediction call. | - -### `PromptFeedback` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A set of the feedback metadata the prompt specified in GenerateContentRequest.content. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `blockReason` | 否 | `string` | `BLOCK_REASON_UNSPECIFIED`, `SAFETY`, `OTHER`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `IMAGE_SAFETY` | Optional. If set, the prompt was blocked and no candidates are returned. Rephrase the prompt. | -| `safetyRatings` | 否 | `array` | - | Ratings for safety of the prompt. There is at most one rating per category. | - -### `ResponseFormatConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration for the response output format. This is a flat object where each optional sub-field configures a specific output modality. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `audio` | 否 | `AudioResponseFormat` | - | Optional. Audio output format configuration. | -| `image` | 否 | `ImageResponseFormat` | - | Optional. Image output format configuration. | -| `text` | 否 | `TextResponseFormat` | - | Optional. Text output format configuration. | - -### `RetrievalConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Retrieval config. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `languageCode` | 否 | `string` | - | Optional. The language code of the user. Language code for content. Use language tags defined by [BCP47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). | -| `latLng` | 否 | `LatLng` | - | Optional. The location of the user. | - -### `RetrievalMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Metadata related to retrieval in the grounding flow. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `googleSearchDynamicRetrievalScore` | 否 | `number(float)` | - | Optional. Score indicating how likely information from google search could help answer the prompt. The score is in the range [0, 1], where 0 is the least likely and 1 is the most … | - -### `RetrievedContext` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Chunk from context retrieved by the file search tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `customMetadata` | 否 | `array` | - | Optional. User-provided metadata about the retrieved context. | -| `fileSearchStore` | 否 | `string` | - | Optional. Name of the FileSearchStore containing the document. Example: fileSearchStores/123 | -| `mediaId` | 否 | `string` | - | Optional. The media blob resource name for multimodal file search results. Format: fileSearchStores/{file_search_store_id}/media/{blob_id} | -| `pageNumber` | 否 | `integer(int32)` | - | Optional. Page number of the retrieved context, if applicable. | -| `text` | 否 | `string` | - | Optional. Text of the chunk. | -| `title` | 否 | `string` | - | Optional. Title of the document. | -| `uri` | 否 | `string` | - | Optional. URI reference of the semantic retrieval document. | - -### `ReviewSnippet` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Encapsulates a snippet of a user review that answers a question about the features of a specific place in Google Maps. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `googleMapsUri` | 否 | `string` | - | A link that corresponds to the user review on Google Maps. | -| `reviewId` | 否 | `string` | - | The ID of the review snippet. | -| `title` | 否 | `string` | - | Title of the review. | - -### `SafetyRating` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Safety rating for a piece of content. The safety rating contains the category of harm and the harm probability level in that category for a piece of content. Content is classified… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `blocked` | 否 | `boolean` | - | Was this content blocked because of this rating? | -| `category` | 否 | `string` | `HARM_CATEGORY_UNSPECIFIED`, `HARM_CATEGORY_DEROGATORY`, `HARM_CATEGORY_TOXICITY`, `HARM_CATEGORY_VIOLENCE`, `HARM_CATEGORY_SEXUAL`, `HARM_CATEGORY_MEDICAL`, `HARM_CATEGORY_DANGEROUS`, `HARM_CATEGORY_HARASSMENT`, `HARM_CATEGORY_HATE_SPEECH`, `HARM_CATEGORY_SEXUALLY_EXPLICIT`, `HARM_CATEGORY_DANGEROUS_CONTENT`, `HARM_CATEGORY_CIVIC_INTEGRITY` | Required. The category for this rating. | -| `probability` | 否 | `string` | `HARM_PROBABILITY_UNSPECIFIED`, `NEGLIGIBLE`, `LOW`, `MEDIUM`, `HIGH` | Required. The probability of harm for this content. | - -### `SafetySetting` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Safety setting, affecting the safety-blocking behavior. Passing a safety setting for a category changes the allowed probability that content is blocked. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `category` | 否 | `string` | `HARM_CATEGORY_UNSPECIFIED`, `HARM_CATEGORY_DEROGATORY`, `HARM_CATEGORY_TOXICITY`, `HARM_CATEGORY_VIOLENCE`, `HARM_CATEGORY_SEXUAL`, `HARM_CATEGORY_MEDICAL`, `HARM_CATEGORY_DANGEROUS`, `HARM_CATEGORY_HARASSMENT`, `HARM_CATEGORY_HATE_SPEECH`, `HARM_CATEGORY_SEXUALLY_EXPLICIT`, `HARM_CATEGORY_DANGEROUS_CONTENT`, `HARM_CATEGORY_CIVIC_INTEGRITY` | Required. The category for this setting. | -| `threshold` | 否 | `string` | `HARM_BLOCK_THRESHOLD_UNSPECIFIED`, `BLOCK_LOW_AND_ABOVE`, `BLOCK_MEDIUM_AND_ABOVE`, `BLOCK_ONLY_HIGH`, `BLOCK_NONE`, `OFF` | Required. Controls the probability threshold at which harm is blocked. | - -### `Schema` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The Schema object allows the definition of input and output data types. These types can be objects, but also primitives and arrays. Represents a select subset of an [OpenAPI 3.0 s… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `anyOf` | 否 | `array` | - | Optional. The value should be validated against any (one or more) of the subschemas in the list. | -| `default` | 否 | `any` | - | Optional. Default value of the field. Per JSON Schema, this field is intended for documentation generators and doesn't affect validation. Thus it's included here and ignored so th… | -| `description` | 否 | `string` | - | Optional. A brief description of the parameter. This could contain examples of use. Parameter description may be formatted as Markdown. | -| `enum` | 否 | `array` | - | Optional. Possible values of the element of Type.STRING with enum format. For example we can define an Enum Direction as : {type:STRING, format:enum, enum:["EAST", NORTH", "SOUTH"… | -| `example` | 否 | `any` | - | Optional. Example of the object. Will only populated when the object is the root. | -| `format` | 否 | `string` | - | Optional. The format of the data. Any value is allowed, but most do not trigger any special functionality. | -| `items` | 否 | `Schema` | - | Optional. Schema of the elements of Type.ARRAY. | -| `maxItems` | 否 | `string(int64)` | - | Optional. Maximum number of the elements for Type.ARRAY. | -| `maxLength` | 否 | `string(int64)` | - | Optional. Maximum length of the Type.STRING | -| `maxProperties` | 否 | `string(int64)` | - | Optional. Maximum number of the properties for Type.OBJECT. | -| `maximum` | 否 | `number(double)` | - | Optional. Maximum value of the Type.INTEGER and Type.NUMBER | -| `minItems` | 否 | `string(int64)` | - | Optional. Minimum number of the elements for Type.ARRAY. | -| `minLength` | 否 | `string(int64)` | - | Optional. SCHEMA FIELDS FOR TYPE STRING Minimum length of the Type.STRING | -| `minProperties` | 否 | `string(int64)` | - | Optional. Minimum number of the properties for Type.OBJECT. | -| `minimum` | 否 | `number(double)` | - | Optional. SCHEMA FIELDS FOR TYPE INTEGER and NUMBER Minimum value of the Type.INTEGER and Type.NUMBER | -| `nullable` | 否 | `boolean` | - | Optional. Indicates if the value may be null. | -| `pattern` | 否 | `string` | - | Optional. Pattern of the Type.STRING to restrict a string to a regular expression. | -| `properties` | 否 | `object/map` | - | Optional. Properties of Type.OBJECT. | -| `propertyOrdering` | 否 | `array` | - | Optional. The order of the properties. Not a standard field in open api spec. Used to determine the order of the properties in the response. | -| `required` | 否 | `array` | - | Optional. Required properties of Type.OBJECT. | -| `title` | 否 | `string` | - | Optional. The title of the schema. | -| `type` | 否 | `string` | `TYPE_UNSPECIFIED`, `STRING`, `NUMBER`, `INTEGER`, `BOOLEAN`, `ARRAY`, `OBJECT`, `NULL` | Required. Data type. | - -### `SearchEntryPoint` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Google search entry point. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `renderedContent` | 否 | `string` | - | Optional. Web content snippet that can be embedded in a web page or an app webview. | -| `sdkBlob` | 否 | `string(byte)` | - | Optional. Base64 encoded JSON representing array of tuple. | - -### `SearchTypes` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Different types of search that can be enabled on the GoogleSearch tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `imageSearch` | 否 | `ImageSearch` | - | Optional. Enables image search. Image bytes are returned. | -| `webSearch` | 否 | `WebSearch` | - | Optional. Enables web search. Only text results are returned. | - -### `SemanticRetrieverChunk` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Identifier for a Chunk retrieved via Semantic Retriever specified in the GenerateAnswerRequest using SemanticRetrieverConfig. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `chunk` | 否 | `string` | - | Output only. Name of the Chunk containing the attributed text. Example: corpora/123/documents/abc/chunks/xyz | -| `source` | 否 | `string` | - | Output only. Name of the source matching the request's SemanticRetrieverConfig.source. Example: corpora/123 or corpora/123/documents/abc | - -### `SpeakerVoiceConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The configuration for a single speaker in a multi speaker setup. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `speaker` | 否 | `string` | - | Required. The name of the speaker to use. Should be the same as in the prompt. | -| `voiceConfig` | 否 | `VoiceConfig` | - | Required. The configuration for the voice to use. | - -### `SpeechConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Config for speech generation and transcription. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `languageCode` | 否 | `string` | - | Optional. The IETF [BCP-47](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) language code that the user configured the app to use. Used for speech recognition and synthesis. Valid v… | -| `multiSpeakerVoiceConfig` | 否 | `MultiSpeakerVoiceConfig` | - | Optional. The configuration for the multi-speaker setup. It is mutually exclusive with the voice_config field. | -| `voiceConfig` | 否 | `VoiceConfig` | - | The configuration in case of single-voice output. | - -### `Status` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The Status type defines a logical error model that is suitable for different programming environments, including REST APIs and RPC APIs. It is used by [gRPC](https://github.com/gr… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `code` | 否 | `integer(int32)` | - | The status code, which should be an enum value of google.rpc.Code. | -| `details` | 否 | `array>` | - | A list of messages that carry the error details. There is a common set of message types for APIs to use. | -| `message` | 否 | `string` | - | A developer-facing error message, which should be in English. Any user-facing error message should be localized and sent in the google.rpc.Status.details field, or localized by th… | - -### `StreamableHttpTransport` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A transport that can stream HTTP requests and responses. Next ID: 6 | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `headers` | 否 | `object/map` | - | Optional: Fields for authentication headers, timeouts, etc., if needed. | -| `sseReadTimeout` | 否 | `string(google-duration)` | - | Timeout for SSE read operations. | -| `terminateOnClose` | 否 | `boolean` | - | Whether to close the client session when the transport closes. | -| `timeout` | 否 | `string(google-duration)` | - | HTTP timeout for regular operations. | -| `url` | 否 | `string` | - | The full URL for the MCPServer endpoint. Example: "https://api.example.com/mcp" | - -### `TextResponseFormat` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Configuration for text output format. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `mimeType` | 否 | `string` | `MIME_TYPE_UNSPECIFIED`, `APPLICATION_JSON`, `TEXT_PLAIN` | Optional. The MIME type of the text output. | -| `schema` | 否 | `any` | - | Optional. The JSON schema that the output should conform to. Only applicable when mime_type is APPLICATION_JSON. | - -### `ThinkingConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Config for thinking features. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `includeThoughts` | 否 | `boolean` | - | Indicates whether to include thoughts in the response. If true, thoughts are returned only when available. | -| `thinkingBudget` | 否 | `integer(int32)` | - | The number of thoughts tokens that the model should generate. | -| `thinkingLevel` | 否 | `string` | `THINKING_LEVEL_UNSPECIFIED`, `MINIMAL`, `LOW`, `MEDIUM`, `HIGH` | Optional. Controls the maximum depth of the model's internal reasoning process before it produces a response. The default value is model-dependent. Refer to the [Thinking levels g… | - -### `Tool` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Tool details that the model may use to generate response. A Tool is a piece of code that enables the system to interact with external systems to perform an action, or set of actio… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `codeExecution` | 否 | `CodeExecution` | - | Optional. Enables the model to execute code as part of generation. | -| `computerUse` | 否 | `ComputerUse` | - | Optional. Tool to support the model interacting directly with the computer. If enabled, it automatically populates computer-use specific Function Declarations. | -| `fileSearch` | 否 | `FileSearch` | - | Optional. FileSearch tool type. Tool to retrieve knowledge from Semantic Retrieval corpora. | -| `functionDeclarations` | 否 | `array` | - | Optional. A list of FunctionDeclarations available to the model that can be used for function calling. The model or system does not execute the function. Instead the defined funct… | -| `googleMaps` | 否 | `GoogleMaps` | - | Optional. Tool that allows grounding the model's response with geospatial context related to the user's query. | -| `googleSearch` | 否 | `GoogleSearch` | - | Optional. GoogleSearch tool type. Tool to support Google Search in Model. Powered by Google. | -| `googleSearchRetrieval` | 否 | `GoogleSearchRetrieval` | - | Optional. Retrieval tool that is powered by Google search. | -| `mcpServers` | 否 | `array` | - | Optional. MCP Servers to connect to. | -| `urlContext` | 否 | `UrlContext` | - | Optional. Tool to support URL context retrieval. | - -### `ToolCall` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | A predicted server-side ToolCall returned from the model. This message contains information about a tool that the model wants to invoke. The client is NOT expected to execute this… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `args` | 否 | `object/map` | - | Optional. The tool call arguments. Example: {"arg1" : "value1", "arg2" : "value2" , ...} | -| `id` | 否 | `string` | - | Optional. Unique identifier of the tool call. The server returns the tool response with the matching id. | -| `toolType` | 否 | `string` | `TOOL_TYPE_UNSPECIFIED`, `GOOGLE_SEARCH_WEB`, `GOOGLE_SEARCH_IMAGE`, `URL_CONTEXT`, `GOOGLE_MAPS`, `FILE_SEARCH` | Required. The type of tool that was called. | - -### `ToolConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The Tool configuration containing parameters for specifying Tool use in the request. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `functionCallingConfig` | 否 | `FunctionCallingConfig` | - | Optional. Function calling config. | -| `includeServerSideToolInvocations` | 否 | `boolean` | - | Optional. If true, the API response will include the server-side tool calls and responses within the Content message. This allows clients to observe the server's tool interactions. | -| `retrievalConfig` | 否 | `RetrievalConfig` | - | Optional. Retrieval config. | - -### `ToolResponse` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The output from a server-side ToolCall execution. This message contains the results of a tool invocation that was initiated by a ToolCall from the model. The client should pass th… | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `id` | 否 | `string` | - | Optional. The identifier of the tool call this response is for. | -| `response` | 否 | `object/map` | - | Optional. The tool response. | -| `toolType` | 否 | `string` | `TOOL_TYPE_UNSPECIFIED`, `GOOGLE_SEARCH_WEB`, `GOOGLE_SEARCH_IMAGE`, `URL_CONTEXT`, `GOOGLE_MAPS`, `FILE_SEARCH` | Required. The type of tool that was called, matching the tool_type in the corresponding ToolCall. | - -### `TopCandidates` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Candidates with top log probabilities at each decoding step. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `candidates` | 否 | `array` | - | Sorted by log probability in descending order. | - -### `UrlContext` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Tool to support URL context retrieval. | - -### `UrlContextMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Metadata related to url context retrieval tool. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `urlMetadata` | 否 | `array` | - | List of url context. | - -### `UrlMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Context of the a single url retrieval. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `retrievedUrl` | 否 | `string` | - | Retrieved url by the tool. | -| `urlRetrievalStatus` | 否 | `string` | `URL_RETRIEVAL_STATUS_UNSPECIFIED`, `URL_RETRIEVAL_STATUS_SUCCESS`, `URL_RETRIEVAL_STATUS_ERROR`, `URL_RETRIEVAL_STATUS_PAYWALL`, `URL_RETRIEVAL_STATUS_UNSAFE` | Status of the url retrieval. | - -### `UsageMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Metadata on the generation request's token usage. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `cacheTokensDetails` | 否 | `array` | - | Output only. List of modalities of the cached content in the request input. | -| `cachedContentTokenCount` | 否 | `integer(int32)` | - | Number of tokens in the cached part of the prompt (the cached content) | -| `candidatesTokenCount` | 否 | `integer(int32)` | - | Total number of tokens across all the generated response candidates. | -| `candidatesTokensDetails` | 否 | `array` | - | Output only. List of modalities that were returned in the response. | -| `promptTokenCount` | 否 | `integer(int32)` | - | Number of tokens in the prompt. When cached_content is set, this is still the total effective prompt size meaning this includes the number of tokens in the cached content. | -| `promptTokensDetails` | 否 | `array` | - | Output only. List of modalities that were processed in the request input. | -| `serviceTier` | 否 | `string` | `unspecified`, `standard`, `flex`, `priority` | Output only. Service tier of the request. | -| `thoughtsTokenCount` | 否 | `integer(int32)` | - | Output only. Number of tokens of thoughts for thinking models. | -| `toolUsePromptTokenCount` | 否 | `integer(int32)` | - | Output only. Number of tokens present in tool-use prompt(s). | -| `toolUsePromptTokensDetails` | 否 | `array` | - | Output only. List of modalities that were processed for tool-use request inputs. | -| `totalTokenCount` | 否 | `integer(int32)` | - | Total token count for the generation request (prompt + thoughts + response candidates). | - -### `VideoFileMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Metadata for a video File. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `videoDuration` | 否 | `string(google-duration)` | - | Duration of the video. | - -### `VideoMetadata` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Deprecated: Use GenerateContentRequest.processing_options instead. Metadata describes the input video content. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `endOffset` | 否 | `string(google-duration)` | - | Optional. The end offset of the video. | -| `fps` | 否 | `number(double)` | - | Optional. The frame rate of the video sent to the model. If not specified, the default value will be 1.0. The fps range is (0.0, 24.0]. | -| `startOffset` | 否 | `string(google-duration)` | - | Optional. The start offset of the video. | - -### `VoiceConfig` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | The configuration for the voice to use. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `prebuiltVoiceConfig` | 否 | `PrebuiltVoiceConfig` | - | The configuration for the prebuilt voice to use. | - -### `Web` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Chunk from the web. | - -| 字段 | 必填 | 类型 | 枚举/常量 | 说明 | -| --- | --- | --- | --- | --- | -| `title` | 否 | `string` | - | Output only. Title of the chunk. | -| `uri` | 否 | `string` | - | Output only. URI reference of the chunk. | - -### `WebSearch` - -| 项 | 值 | -| --- | --- | -| 类型 | `object` | -| 说明 | Standard web search for grounding and related configurations. | diff --git a/docs/api/rerank.md b/docs/api/rerank.md deleted file mode 100644 index f46ebfe93..000000000 --- a/docs/api/rerank.md +++ /dev/null @@ -1,58 +0,0 @@ -# Rerank API - -Aether exposes an OpenAI-compatible rerank surface at `POST /v1/rerank` and can route it to providers configured as `openai:rerank` or `jina:rerank`. - -## Request - -```http -POST /v1/rerank -Authorization: Bearer -Content-Type: application/json -``` - -```json -{ - "model": "bge-reranker-base", - "query": "What document discusses gateway routing?", - "documents": [ - "Aether routes public AI requests through the Rust gateway.", - "This document discusses unrelated content." - ], - "top_n": 1, - "return_documents": true -} -``` - -Fields: - -| Field | Required | Notes | -| --- | --- | --- | -| `model` | Yes | Aether global model name. | -| `query` | Yes | Non-empty query string. | -| `documents` | Yes | Non-empty array of strings or provider-native document objects. | -| `top_n` | No | Positive integer. | -| `return_documents` | No | Provider-compatible flag for including matched documents. | - -## Response - -Aether forwards the provider JSON response. OpenAI-compatible and Jina-compatible rerank providers commonly return `results[]`: - -```json -{ - "model": "bge-reranker-base", - "results": [ - { - "index": 0, - "relevance_score": 0.98, - "document": { - "text": "Aether routes public AI requests through the Rust gateway." - } - } - ], - "usage": { - "total_tokens": 32 - } -} -``` - -Rerank requests must be JSON and do not support `stream` or chat `messages` payloads. diff --git a/docs/operations/client-disconnect-policy.md b/docs/operations/client-disconnect-policy.md deleted file mode 100644 index fabb8f434..000000000 --- a/docs/operations/client-disconnect-policy.md +++ /dev/null @@ -1,39 +0,0 @@ -# 调度策略:取消请求立即打断 - -管理入口:**调度策略配置 → 系统配置 → 取消请求立即打断**。 - -配置保存在当前策略的 `config_json.default_policy.cancel_on_client_disconnect`,默认 `false`。 -旧策略缺失此字段也按关闭处理;不需要数据库结构迁移,不读取同名全局系统设置。 -策略选定后,请求沿用该次解析的配置,不因管理员随后修改策略而改变断连处理。 - -| 配置 | 客户端取消/断连时 | 计费 | -| --- | --- | --- | -| 关闭(默认) | 已选定策略的请求继续执行,后台读取响应直到正常完成或原有超时/上游错误 | 按实际完成结果正常结算 | -| 开启 | 中止仍在执行的请求、停止读取上游响应 | Token、缓存 Token 和图片产出费用不收取;配置了 `price_per_request` 的请求保留一次请求费用及对应倍率 | - -已经取得上游终态的请求不会因最后一跳投递失败而撤销已完成的结算。 -取消按次收费的记录仍显示 `cancelled`/499,但计费状态可为 `settled`;不要仅凭请求状态判断是否收费。 - -## 执行边界 - -- HTTP 同步、SSE、同格式直通和 CF 心跳响应共用请求生命周期保护。 -- Responses WebSocket 断连后只完成当前进行中的 turn,不无限维持空闲连接;开启开关则直接终止当前 turn。 -- 鉴权、请求体接收等尚未选定策略的阶段仍可直接取消。 -- Live/Realtime 长连接会话关闭、异步任务显式取消不是有限 HTTP/Responses 请求的断连续跑,不转成后台常驻会话。 -- 原有上游总超时、首字节超时及故障处理仍有效;这里不创建持久化后台作业,进程退出不能保证继续执行。 - -## 代码调整 - -- `request_lifecycle.rs` 将 HTTP 请求 Future 和响应 Body 的所有权与客户端连接解耦。正常连接保留原来的逐帧路径、响应头、长度提示和 trailers;仅断连时启动后台接管,逐帧丢弃待发送内容,不聚合完整响应。 -- 请求准入凭证随原 Future/Body 保留至完成,防止断连提前释放并发额度;诊断上下文同时保留。 -- CF 心跳后台执行显式监听响应接收端关闭,避免打开开关后仍继续执行。 -- Responses WebSocket 将“客户端已断开”和“上游已完成”分别处理,复用既有终态观察、超时和结算逻辑。 -- 计费统一在 billing enrichment 中计算取消请求的单次费用。`cancelled_request_fee` 由服务端计算后标记,贯穿审计持久化、钱包结算及套餐成本预留结算,避免有费用却仍写为 `void` 或释放成本预留。 - -## 回归覆盖 - -- 新旧配置默认关闭、布尔校验、策略保存及模型级调度编辑保留开关。 -- 响应头前断连、首帧前/首帧后断连、立即取消、并发额度、诊断信息、响应头/trailers 透传。 -- 真实流式执行链路的完成/取消 usage 与候选状态,以及心跳取消。 -- 取消时按次/按 Token/混合定价、图片请求单次费、倍率、审计状态、钱包及成本预留结算。 -- Responses WebSocket 使用真实网关和临时 PostgreSQL 验证断连继续完成、开启后不收费,以及按次费用实际结算。 diff --git a/docs/operations/codex-responses-websocket-probe.md b/docs/operations/codex-responses-websocket-probe.md deleted file mode 100644 index 2e936a349..000000000 --- a/docs/operations/codex-responses-websocket-probe.md +++ /dev/null @@ -1,190 +0,0 @@ -# Codex Responses WebSocket probe - -`aether-codex-ws-probe` is a P0 compatibility probe for a Codex-compatible -Responses WebSocket upstream. It verifies two sequential `response.create` -warmups on one socket, with the second request continuing from the first -response ID. - -The command, environment variables, JSON report shape, and Codex-specific -handshake headers remain stable. It now shares only the protocol-driving core -with the separate [OpenAI Responses WebSocket probe](openai-responses-websocket-probe.md); -the two probes intentionally retain independent authentication profiles and -provider-specific assertions. - -The probe is intentionally not a production proxy. It does not persist, -refresh, log, or print credentials, account IDs, response IDs, request bodies, -or response bodies. - -## Prerequisites - -Use a dedicated, non-production Codex test account. Rotate any credential that -has been pasted into a chat, terminal history, issue, or source file before -using this probe. - -Set these values only in the process environment or your secret manager: - -```bash -export AETHER_CODEX_WS_PROBE_URL='wss://your-codex-upstream.example/backend-api/codex/responses' -export AETHER_CODEX_WS_PROBE_ACCESS_TOKEN='your-short-lived-access-token' -export AETHER_CODEX_WS_PROBE_ACCOUNT_ID='your-account-id' -export AETHER_CODEX_WS_PROBE_MODEL='your-codex-model' -``` - -The endpoint must use `ws://` or `wss://`, with no credentials, query string, -or fragment. The access token is accepted only through -`AETHER_CODEX_WS_PROBE_ACCESS_TOKEN`; there is deliberately no command-line -flag for it. - -## Run - -```bash -cargo run -p aether-gateway --bin aether-codex-ws-probe -``` - -Use `--url` to override only the endpoint and `--timeout-secs` to set a -per-turn receive timeout (1–120 seconds): - -```bash -cargo run -p aether-gateway --bin aether-codex-ws-probe -- \ - --url 'wss://your-codex-upstream.example/backend-api/codex/responses' \ - --timeout-secs 30 -``` - -The probe emits one JSON line. A successful run has -`"continuation_confirmed":true`; its event and header fields contain names -only, never values. A failure emits a stable error code such as -`"handshake_failed"`, `"upstream_error_event"`, or -`"response_id_not_observed"`. - -## Interpretation - -A successful probe establishes that the selected upstream accepts the -Responses WebSocket handshake and retains continuation state on one socket. -It does not establish that all Codex models, account plans, or tunnel egress -paths are supported. In particular, the current `aether-tunnel` HTTP relay -does not forward WebSocket upgrades, so a successful direct probe is a -prerequisite rather than tunnel support. - -## Gateway bridge - -The gateway exposes WebSocket mode at the same public Responses path: - -```text -wss:///v1/responses -``` - -It is disabled by default per provider. In **添加提供商** or **编辑提供商**, -enable **Responses WebSocket 模式** under **功能开关** only after the selected -upstream has passed a compatible WebSocket probe. The setting takes effect for -new WebSocket connections without a gateway restart. It is available to every -provider type; candidate planning still requires a selected -`openai:responses` endpoint. - -Authenticate the upgrade request with the normal Aether API key. The first -client frame must be a text JSON `response.create` containing a non-empty -`model`. Aether then applies its regular Responses candidate selection, but -accepts only an eligible, WebSocket-enabled endpoint using `openai:responses`. -It opens an upstream WebSocket using the selected provider key. - -The selected provider's model mapping and request headers are applied to every -turn, along with the rest of that candidate's provider-body normalization: -model-directive patches, endpoint body rules, and the Codex body contract -(unsupported-field stripping, its HTTP `store: false` default, and -`tool_choice` defaulting). An explicitly supplied WebSocket `store` value is -restored unchanged after that HTTP-oriented normalization. A -continuation turn with a non-null `previous_response_id` is revalidated through -the current scheduler and normalized against its pinned binding. It can never -move to another provider key, and it is rejected if that exact candidate is no -longer eligible or its physical binding changed. -`store`, `previous_response_id`, and `generate` are re-applied after -normalization because they are WebSocket protocol state that the provider body -contract may otherwise rewrite or strip. `stream` and `background` are removed -because they are HTTP transport fields, not WebSocket-mode fields. Every -independent `response.create` (one without a non-null `previous_response_id`) -runs access checks and candidate planning again, even when the public model is unchanged. -It keeps the existing upstream when the same target remains eligible, or -transparently replaces the upstream between responses when the selected target -changes. Overlapping responses on one client socket remain rejected. - -Each `response.create` is tracked as an independent Aether logical request: -it receives its own request/candidate identity, usage lifecycle, and terminal -audit record. `response.completed`, `response.failed`, -`response.incomplete`, `response.cancelled`, client disconnects, and upstream -transport failures all settle that turn through the existing stream reporting -path. - -Example client setup: - -```python -from websocket import create_connection -import json -import os - -ws = create_connection( - "wss://gateway.example/v1/responses", - header=[f"Authorization: Bearer {os.environ['AETHER_API_KEY']}"], -) -ws.send(json.dumps({ - "type": "response.create", - "model": "your-public-model", - "store": False, - "input": "Explain this repository.", -})) -``` - -### Operating limits - -- Maximum frame and message size: 16 MiB. -- An idle connection must send its first `response.create` within 60 seconds. -- A connection is closed after 60 minutes; reconnect before then for long runs. -- Each `response.create` must receive its first upstream event within the - selected provider's `stream_first_byte_timeout` (30 seconds by default), - and finish within its `request_timeout` (20 minutes by default). Aether - sends `responses_websocket_first_event_timeout` or - `responses_websocket_turn_timeout` and closes the bound socket when either - deadline expires. -- Responses are sequential; no multiplexing is supported on one socket. -- Each `response.create` consumes the normal Aether user/API-key RPM budget. -- Continuations with a non-null `previous_response_id` stay on the bound - provider key. Independent turns are re-authorized and re-planned each time; - they reuse the socket only when planning selects the same physical target. -- Direct provider proxy settings are honored through the selected transport - profile. Tunnel-mode proxy nodes are not supported for this bridge yet. - -### DNS and proxy behavior - -The gateway's plain and browser-profile WebSocket clients share the HTTP/SSE -provider DNS resolver. Provider hostname answers are not filtered by address -range, including Fake-IP answers such as `198.18.0.0/15`. DNS lookup timeout, -answer-count limits, and rejection of empty answers still apply. Resolution -happens during connection establishment, not while building the client; DNS -failures therefore surface as upstream handshake failures rather than invalid -upstream URLs. - -This policy applies only to configured provider hostnames. URL validation still -rejects credentials, fragments, and literal private/reserved IP targets (except -loopback `ws://`), and the gateway frontdoor self-loop guard remains active. -Tunnel owner-relay DNS address filtering is unchanged. - -Configure an explicit HTTP(S) or SOCKS proxy on the provider when needed; these -clients do not automatically use system proxy environment variables. A proxy -connection does not trigger a separate gateway-side lookup of the provider -hostname. Existing SOCKS remote-DNS normalization remains in effect. - -The [DNS egress audit](dns-egress-audit-2026-09-08.md) documents the separate -tunnel and untrusted-download policies, regression coverage, and remaining -deployment limitations; not every outbound path uses the provider DNS policy. - -### Usage and logging - -Usage and audit finalization now runs for every accepted `response.create`. -Existing usage body-capture and header-redaction policies apply to the resulting -records. Newly created WebSocket usage records expose `is_websocket=true`, and -the usage-record type column renders them as `WS`. For diagnosis, enable debug -logging for `aether_gateway::handlers::proxy::responses_ws`; event logs contain -only the event type and frame size, never request or response contents. Codex -quota-extension logs remain under `aether_gateway::handlers::proxy::codex_ws`. -Every WebSocket-specific log carries `transport="websocket"` and -`websocket=true`; keep `log_type` for its existing access/event/ops -classification, and render the transport flag as a `WS` label in a log viewer -if desired. diff --git a/docs/operations/concurrency-design-audit-2026-09-09.md b/docs/operations/concurrency-design-audit-2026-09-09.md deleted file mode 100644 index a81109632..000000000 --- a/docs/operations/concurrency-design-audit-2026-09-09.md +++ /dev/null @@ -1,526 +0,0 @@ -# Aether 并发设计审查 - -- 日期:2026-09-09 -- 代码基线:`361952ada` -- 背景:RPM 增加后服务出现卡死现象。 -- 范围:当前代码、默认配置、隔离本地复现。尚未取得故障实例、实际 RPM、线上配置或卡死时指标;以下是确认的代码问题及条件性风险,不代表已经确认本次生产故障根因。 -- 初次审查仅新增报告;后续本地修复状态见下文。未部署、未修改生产配置、未向线上发起压测。 - -## 第一轮修复状态 - -以下问题描述及行号对应审查基线 `361952ada`,不是修复后的代码位置。 - -- **P1-1 已修复:** 压缩上传按声明的压缩大小预留,未知长度上传从一个额度单元开始,随缓冲容量增长申请预算;解压计入同时存活的输入、中间输出和最终输出。扩容额度不足立即返回 503,避免多个请求各持部分额度互相等待;取消和失败释放额度。请求体完整读取默认超时改为 120 秒,显式配置 0 仍可关闭。该预算覆盖读取和解压的显式缓冲,不等于整个请求生命周期或进程 RSS 上限。 -- **P1-3 已修复:** SQL 改为 `FOR UPDATE OF user_plan_entitlements`,不同用户不再争抢共享套餐行,同一 entitlement 的扣费仍串行。套餐 overage 配置按当前语句快照读取,后续语句可读取已提交的配置变更。 -- **P1-4 已修复:** reqwest、h2c、wreq 和 tunnel 的流统一接入空闲读取期限;执行配置 `read_ms` 优先,否则使用 `AETHER_GATEWAY_UPSTREAM_STREAM_IDLE_TIMEOUT_MS`,默认 300 秒,显式 0 可关闭。首包期限独立保留,网关 keepalive 和空数据帧不重置空闲计时;已收到成功终止事件的请求不会因随后空闲被改记为失败。 -- **后续待处理:** P1-2 Redis 调度扫描、P1-5 响应捕获全局预算、P1-6 审计压缩及 P1-7 长期额度聚合和 SQL 期限,本轮未修改。 - -本地验证: - -- `cargo test -p aether-gateway-frontdoor`:34 项通过。 -- 网关请求体、解压、超时、流结束及模块边界的针对性回归:73 项通过;另跑 Anthropic 原生流及直通兼容性回归:24 项通过。 -- `cargo test -p aether-data-postgres --lib settlement::tests`:6 项通过,1 项需要数据库的测试默认忽略;该测试另在隔离 PostgreSQL 14.17 中显式执行通过,覆盖不同用户并行、同用户阻塞、扣费余额及配置并发更新,临时实例已停止。 -- 修改文件格式检查和 `git diff --check` 通过。未执行全工作区测试或阶梯吞吐压测,尚不能据此给出修复后的 RPM 容量。 - -## 第二轮修复状态 - -- **P1-2 已处理调度中的管理扫描和无关指标读取:** 增加调度专用运行态入口,跳过全池 sticky 会话扫描、会话计数及 cooldown TTL;sticky 直达只查询当前绑定。成本窗口仅在成本限额、`cost_first` 或 `quota_balanced` 启用时读取,延迟窗口仅在 `latency_first` 启用时读取。默认 64 个候选且未启用这些策略时,消除原先 128 次历史窗口查询;这是代码路径比较,不是压测吞吐结论。管理查询仍保留原统计,调度成本检查不再套用管理显示的 key 数量截断。 -- **P1-5 已增加流式诊断捕获共享预算:** `AETHER_GATEWAY_STREAM_CAPTURE_MEMORY_BUDGET_BYTES` 默认 128 MiB,覆盖 provider/client 捕获的实际容量及扩容时的新旧分配,显式 0 关闭此类捕获。预算不足时保留连续前缀并标为截断,不阻塞客户端传输;取消和终态释放额度。计费观察独立消费完整数据,补充主解析器停用后的用量恢复,并保留同步 JSON 转流的终态摘要。完成语义恢复后及时释放预读副本。 -- **P1-6 已处理单条及 pending 批量写入的审计压缩:** 准备工作移到事务前的 blocking 任务,进程内最多 4 个工作任务、32 个含等待的准入任务,等待工作槽最多 1 秒、等待执行结果最多 30 秒。超时和容量不足返回可重试 `TimedOut`;取消后的运行任务继续持有许可,且只准备数据,不会自行写数据库。序列化直接写入带 8 KiB 缓冲的 gzip,避免完整 JSON 中间副本及普通路径一次额外 body 克隆。事务内仍保留依赖旧记录的生命周期、清空、恢复和幂等判断。 - -本地验证: - -- PostgreSQL usage 模块:124 项通过,13 项需要外部条件的测试默认忽略。 -- 隔离 PostgreSQL 14.17 的 5 项真实回归通过,覆盖单条/批量完整审计读写、重复事件计数、过期终态 no-op 和捕获清空;临时数据库已停止。日志:`/tmp/aether-usage-concurrency.68ZESf/test.log`。 -- 第一批网关回归 68 项通过,包含真实 Redis 命令计数、管理统计保留、策略读取矩阵、超过管理显示上限的成本检查及调度结果一致性。 -- 最终流式模块及相关超时回归 187 项全部通过,覆盖捕获预算耗尽、并发预算释放、用量回退、累计更新及显式归零、协议转换、同步 JSON 桥接和非对象字段兼容。日志:`/tmp/aether-concurrency-round2-stream-final.log`。 -- 修改过的 Rust 文件格式检查和 `git diff --check` 通过。 - -仍需后续处理的边界: - -- 启用成本/延迟策略时仍读取原始窗口,尚未改成增量聚合或合并刷新。 -- 128 MiB 不包含独立协议/计费解析缓冲、终态 base64 编码或 usage 队列副本。计费用量回退保留的单条协议记录仍有 Basic 5 MiB / Full 64 MiB 上限,记录完成即释放;大量并发超长单记录仍可能放大内存。审计准备许可约束任务数,也不是任意大 usage 记录的字节预算。 -- P1-7 长期额度聚合和 SQL/锁等待期限,以及独立实例阶梯压测,本轮未处理。 - -## 第三轮修复状态 - -- **P1-7 普通 SQL 等待期限:** PostgreSQL 连接默认设置 `statement_timeout=30000ms`、`lock_timeout=3000ms`,由 `AETHER_GATEWAY_DATA_POSTGRES_STATEMENT_TIMEOUT_MS` 和 `AETHER_GATEWAY_DATA_POSTGRES_LOCK_TIMEOUT_MS` 覆盖,显式 0 关闭,非法值在创建连接池时拒绝。覆盖直接连接池查询和事务;这是单条语句期限,不是整笔事务总期限。超时 SQLSTATE 仍按原可重试错误处理。 -- **P1-7 多窗口精确查询:** 长期请求额度和成本额度将多个窗口的独立扫描合并为一条带 `FILTER` 的聚合查询,保留用户锁、时间边界、有效预留过滤、当前事件排除、拒绝顺序和幂等语义。未引入近似额度或缓存;仍需扫描最大覆盖范围内的历史记录。 -- **维护任务隔离:** schema migration 和历史 backfill 使用关闭普通期限的专用连接,所有退出路径均关闭连接,避免配置泄漏回请求池。日/小时统计、钱包每日聚合、用量统计重建与 VACUUM 使用 5 分钟语句期限和 30 秒锁等待期限。 -- **P1-2 有界 Redis 聚合:** 单个窗口最多 512 条记录时在 Redis 内精确聚合,只返回总量和正值样本数;每批最多 16 个独立脚本。更大的窗口或聚合失败回到原完整查询;没有缓存金额,也没有无界 Lua 聚合。超过 512 条的成本窗口仍有原查询开销,并增加一次计数探测。 -- **P1-5/P1-6 事件正文保留:** 已构造的 usage 事件在同步保存计费相关字段后、进入终态队列或等待正文策略前,按四份诊断 JSON 正文的堆内存估算申请共享预算。`AETHER_USAGE_EVENT_CAPTURE_MEMORY_BUDGET_BYTES` 默认 128 MiB,显式 0 关闭正文保留。预算申请不等待,额度不足舍弃正文并标记 `Truncated`,保留费用、用量、终态和引用;事件副本单独申请额度,重试随事件保留额度,取消及释放事件时归还。异步策略读取仍在原来的终态执行和准入位置,维持提交、排序与异常隔离语义。 -- **减少中间副本:** usage envelope 和死信队列序列化改为借用正文及字段,避免序列化前的完整深拷贝。新增 `usage_runtime_event_capture_memory_budget_bytes`、`usage_runtime_event_capture_memory_retained_bytes` 和 `usage_runtime_event_capture_memory_downgraded_total` 指标;降级次数也可能包含随后按 Basic 策略清除的正文。 - -本地验证: - -- Redis 运行态 3 项真实实例回归通过,覆盖 512/513 条边界、多批 Key、`u64` 精度和饱和、脚本重载、窗口变化及已完成的并发写入。日志:`/tmp/aether-round3-redis-tests.log`。 -- PostgreSQL 全量普通测试 229 项通过、24 项默认忽略;跨模块 `cargo check -p aether-data` 通过,维护 future 的 `Send` 回归通过。隔离 PostgreSQL 14.17 显式验证空库迁移和 usage 读写、超时回滚与期限隔离、精确多窗口准入和幂等、共享套餐锁,共 4 项通过。临时库已停止;日志:`/tmp/aether-postgres-deadlines.7sZ8w1/`。 -- 用量模块完整回归 264 项全部通过,含 13 项正文预算测试及终态队列容量、排序、异常隔离回归。日志:`/tmp/aether-round3-usage-agent-tests.log`。 -- 最终网关调度、真实 Redis 窗口回退及指标回归 61 项全部通过;最终网关、mock upstream 和 seed 工具构建通过。日志:`/tmp/aether-round3-gateway-tests.log`、`/tmp/aether-round3-verified-build.log`、`/tmp/aether-round3-seed-final-build.log`。 -- 修复压力 seed 工具按网关密钥加密 provider/client 凭据,并用已有凭据比较更新接口处理随机密文的重复初始化;隔离空库连续初始化两次已通过。mock chat 流按 `stream_options.include_usage=true` 输出终态用量,保留故障截断和未请求用量时的原行为,9 项测试全部通过。日志:`/tmp/aether-round3-mock-tests.log`;最终 mock 构建日志:`/tmp/aether-round3-mock-final-build.log`。 - -### 隔离端到端验收 - -使用本机 debug 构建、独立空 PostgreSQL 14.17 和 Redis、4 个 Tokio worker、入口并发上限 128、8 个客户端 API key。上游为单个零价 mock provider,每请求 20 个 256 字节内容块,首字节延迟 30 ms、块间隔 100 ms,完整流约 2 秒。请求启用流式 usage,客户端必须收到完整响应及 `[DONE]`。各档采样间隔 500 ms,停止流量后最多等待 45 秒排空;指标通过管理员会话采集,避免管理 Token 使用计数干扰排空判断。 - -| 并发 | 请求数 | 成功数 | 实测 RPS | 首字节 P95 (ms) | 总耗时 P95 (ms) | 网关峰值 RSS (MiB) | 排空 (s) | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 8 | 64 | 64 | 4.01 | 117 | 2069 | 107.0 | 8.7 | -| 24 | 192 | 192 | 12.04 | 67 | 2015 | 122.3 | 9.9 | -| 48 | 384 | 384 | 23.91 | 87 | 2036 | 143.3 | 9.8 | - -- 三档共 640 个请求,全部 HTTP 200 且完整 SSE 结束,无请求错误。数据库恰有 640 条 `completed`,每条 input/output/total tokens 均为 `1/20/21`,合计 `640/12800/13440`,所有首字节时间有值。 -- 三档排空所需指标齐全,最终 usage 队列 lag、pending、计数 outbox、终态提交和有序生命周期待处理数均为 0;采样未观察到 PostgreSQL 锁等待,未出现 usage worker 处理失败。最终事件正文预算保留量为 0,未触发正文预算降级。 -- RSS 随并发档位上升,排空时未立即回落;不能据此证明长期无内存增长。最高档约 1435 RPM 是此短时、约 2 秒 mock 流的实测值,不代表生产容量,也没有修复前的同条件吞吐基线。真实长流、大请求、多候选账号池、长期额度和现金扣费负载未在本次阶梯压测覆盖;额度和结算语义由前述独立数据库回归验证。 -- 最终产物:`/tmp/aether-round3-pressure.T3sq7J/`,包含每档 JSON、原始日志、最终指标及 `usage-integrity.txt`;脚本:`/tmp/aether-concurrency-round3-pressure.sh`,总日志:`/tmp/aether-round3-pressure-final.log`。临时网关、mock、采样代理、Redis 和 PostgreSQL 均已停止。修改文件格式检查及 `git diff --check` 通过,未执行全工作区测试,未部署线上。 - -本轮仍未覆盖的内存与计算边界: - -- 事件正文预算是 JSON 堆内存估算,不是进程 RSS 上限。仍不包含计费提取前的 seed、Redis 消费批次、数据库写入 DTO、压缩和序列化字符串,以及协议观察缓冲。 -- Redis 超大成本窗口仍走原始历史查询;长期 SQL 额度仍需扫描历史,尚未改为带迁移和对账的增量账本聚合。 - -## 第四轮修复状态 - -- **受限等待期间减少重复候选准备:** 普通指定模型和无指定模型能力查询保留 150 ms 等待窗口,分页候选路径保留首次受限后的 100 ms 窗口。首次完整准备确认仅被客户端 API Key 并发限制阻塞后,等待期间只读取并发判断所需的近期候选;恢复或到达期限后重新执行完整动态校验,不直接复用旧候选放行。持续阻塞且首次准备未耗尽期限时,完整准备收敛为首次和最终两次;若短暂恢复后重新受限,仍可在原期限内重新准备。等待窗口不重置,也不是包含数据库 I/O 的端到端硬超时。 -- **探测任务创建前合并:** 同一 AppState 的请求补探测按 provider 在 `spawn` 前合并;运行期间的重复触发只保留一次后续执行信号,完成和取消时清理本地条目。AppState clone 共享协调器,切换到不同 RuntimeState 时隔离。最多同时保留 1024 个活跃 provider,超过容量跳过此次 best-effort 补探测,不创建等待任务;周期基础探测继续运行,但不等价于请求触发的 Burst 补探测。 -- **跨实例退出交接:** 保留 Redis pending 和带 token 的锁协议,释放锁后复查新 pending,避免另一实例在退出窗口提交的补探测无人接手。读取 pending 失败时释放锁并停止本轮处理,避免残留 pending 导致重新争锁的忙循环。 -- **可观测性:** 增加 `pool_quota_probe_replenish_provider_capacity`、`pool_quota_probe_replenish_active_providers`、`pool_quota_probe_replenish_started_total`、`pool_quota_probe_replenish_coalesced_total` 和 `pool_quota_probe_replenish_capacity_rejected_total`。 - -本地验证: - -- `cargo check --locked -p aether-gateway --lib` 通过。日志:`/tmp/aether-round4-check.log`。 -- 网关调度、分页候选、探测、模块边界、指标及真实 HTTP 并发等待回归共 376 项全部通过,包含本轮新增的 11 项调度等待和 8 项探测协调测试。日志:`/tmp/aether-round4-gateway-tests.log`。 -- 8 个同时受限的请求,通过真实候选筛选函数的依赖调用计数,确认完整候选读取共 16 次;恢复后重新加载被替换或移除的候选,轻量读取及完整重验的错误均正常传播。分页持续受限仅重启一次扫描,首次查询耗时计入原重试预算,取消后停止查询。 -- 单 provider 的探测运行中并发触发 64 次,仅创建一个任务并额外执行一次补探测。测试覆盖不同 provider 并行、100 次本地退出交接竞争、未首次执行即取消、运行中取消、panic、容量释放和 runtime 绑定隔离。两个本地协调器共享 Memory Runtime 模拟跨实例 pending/锁交接,精确覆盖旧任务最终检查后、解锁前的新触发;没有声称本轮运行了多进程 Redis 压测。 -- `cargo build --locked -p aether-gateway --bin aether-gateway` 通过,最终可执行文件已更新;日志:`/tmp/aether-round4-build.log`。修改文件格式和 `git diff --check` 通过,测试进程已退出。本轮未重新执行阶梯吞吐压测,第三轮的 640 请求结果仅代表其当时构建;未执行全工作区测试,未部署线上。 - -边界: - -- API Key 并发检查仍使用全局最近 128 条候选中的同 Key 活跃候选行,依赖状态持久化及原 300 秒活跃窗口;未引入原子分布式许可,也未改变候选行计数为请求去重计数。 -- 探测协调为 best-effort;取消后的 Redis 锁仍按原 30 秒 TTL 释放,未增加续期或 exactly-once 保证。同步日志、超大历史窗口增量聚合、未覆盖的内存副本以及目标环境长期压测仍待处理。 - -## 第五轮修复状态 - -- **运行日志 I/O 脱离请求线程:** stdout 和滚动文件各使用独立专用写线程,保留 Pretty/JSON、Stdout/File/Both、动态日志过滤及原有文件权限检查。文件写入、flush 和轮转重开均在写线程执行;定期文件清理移到 blocking 任务,单次完成后才安排下一次清理。启动时仍同步校验日志目录和目标文件,配置错误正常拒绝启动。 -- **日志过载保护:** 每个目标最多排队 4096 条事件,复制后的日志正文最多保留 8 MiB,单条事件上限 256 KiB。字节预算包含生产者已预留的复制、排队和正在写入的正文;队列满、预算不足或单条过大时整条舍弃,不等待设备、不在请求线程同步回退 stderr。Both 两个目标独立接收和降级,可能保留不同的事件集合。 -- **退出排空:** 网关、隧道、两个运行示例及 13 个基准工具的最外层入口持有日志 guard,先结束 Tokio runtime 再关闭队列。升级/回滚的显式进程退出也调用关闭接口。关闭先停止全部目标接收,再在共用的 2 秒预算内等待已接收事件及最终 flush;阻塞中的系统 I/O 无法强制取消,超时后不无限 join 写线程。 -- **信号处理:** 网关 ready 后收到 SIGTERM/SIGINT 会结束运行函数并经过日志 guard;现有连接未被统一追踪,此路径仍按进程终止处理业务,不声称请求或用量队列优雅排空。启动过程中尚未进入信号等待时,以及 SIGKILL/abort,不保证排空。 -- **日志健康指标:** 网关与隧道指标端点增加 `logging_stdout_*` / `logging_file_*`,记录队列和字节上限、当前正文保留量、接收数、按原因区分的丢弃数、写入错误、线程 panic、关闭超时及线程状态。沿用服务指标命名空间前缀。关闭 API 返回成功仅说明线程处理完队列且最终 flush 成功,之前的写入错误仍需查看指标,不代表 fsync 持久化成功。 - -本地验证: - -- 网关、隧道、loadtools 和 integration 的所有 binary/example 入口通过 `cargo check --locked`,未增加新的第三方依赖;integration 的依赖清单补充已有共享 runtime。日志:`/tmp/aether-round5-entrypoints-check.log`。 -- 共享 runtime 44 项单元测试及 2 项进程集成测试全部通过,包含 11 项新 writer 测试、2 项轮转回归、12 种初始化/输出格式/动态过滤场景,以及真实 stdout 堵塞测试。8 个生产者同时写入时,慢设备不阻塞生产者;4096 条和字节预算、整条拒绝、错误/panic 回收、退出竞争、stdout/file 双向隔离及阻塞 flush 均已覆盖。每种实际格式并发写入 512 条事件,记录完整且无重复。日志:`/tmp/aether-round5-runtime-final-tests.log`。 -- 真实 stdout 测试保持子进程管道完全不读,确认 stdout 队列触发丢弃后,文件仍收到完整 JSON 尾记录;guard 超时返回后,标准进程退出也成功完成。该测试耗时约 2.36 秒,包含日志的 2 秒退出等待;没有关闭管道来人为解除阻塞。 -- 网关入口 61 项、隧道 197 项回归全部通过,日志:`/tmp/aether-round5-service-tests.log`。以上合计 304 项测试通过,没有计入重复运行的首批测试。 -- Linux root/capabilities 专用日志 fixture 已适配异步排空,当前 macOS 环境未执行;Unix 文件权限、符号链接、多硬链接及安全轮转的普通测试已通过。 -- 网关和隧道最终二进制构建通过,日志:`/tmp/aether-round5-service-build.log`。隔离 PostgreSQL 和新网关实例的 Both/JSON 验收通过,stdout 和文件各保留 15 条完整日志,均包含唯一的 starting、ready 和 shutdown 事件;实际指标端点两路队列容量、接收及运行状态正常,丢弃、写入错误、panic 和关闭超时计数为 0。SIGTERM 后约 16 ms 正常退出,该耗时仅代表健康设备和无在途代理请求的此次烟测。产物:`/tmp/aether-logging-smoke-qvzN9U/result.json`,脚本:`/tmp/aether-nonblocking-logging-smoke.mjs`。 -- 临时网关和 PostgreSQL 已全部停止,测试与构建进程均已退出。修改文件格式检查及 `git diff --check` 通过;本轮未重跑业务阶梯吞吐压测,未执行全工作区测试,未部署线上。 - -本轮边界: - -- 日志格式化、字段 Debug 展开和 JSON 序列化仍发生在调用线程,订阅器线程局部字符串也可能保留历史容量;8 MiB 预算只约束交给后台写入的正文,不包含这些临时对象、队列元数据或进程 RSS。 -- 运行日志在过载、I/O 错误或关闭超时下可能丢失,不能作为可靠计费账本;账务持久化流程不使用这条日志队列。2 秒仅限制日志关闭等待,不是整个服务退出期限,既有业务或 blocking 任务清理仍可能更久。 -- 超大历史窗口增量聚合、尚未覆盖的内存副本、完整请求优雅排空及目标环境长期压测仍需后续处理。本轮不调整线上配置,也不据此给出生产 RPM 容量。 - -## 第六轮修复状态 - -- **补齐诊断正文预算的所有权传递:** 将纯预算令牌放到 data contracts,运行时仍使用原环境变量、128 MiB 默认值和指标。同步及流式终态 seed 在等待提交前申请预算;Redis 解码后的事件重新纳入本进程预算;事件生成的数据库写入 DTO 继续持有对应额度。事件和受管理 DTO 的正文副本分别申请额度,释放正文后才归还;序列化跳过令牌,不改变队列协议或数据库字段。 -- **保留完整计费与终态语义:** 正常终态构建仍在 blocking 任务执行;预算不足时同步执行既有纯构建逻辑,先解析 token、显式 cache=0、图像估算、错误及终态,再舍弃诊断正文,随后进入原有有序提交和准入路径。保留原始终态观测时间,构建异常仍按原终态失败路径隔离。已有 `None`、`Disabled`、`Unavailable` 状态不被预算降级改写为 `Truncated`,避免破坏清空指令。 -- **旧队列消息兼容:** 解码前的原始字段继续保留到记录或死信处理完成,重试不提前 ACK,DLQ 保存原始字段。旧消息缺正文且缺 typed state 时保留元数据内已有缓存 TTL、tier 和请求事实,显式 `None` 仍清空,避免预算接线改变后续计费输入。 -- **数据库准备减少正文副本:** 单条与 pending 批量准备先移走四份正文和 headers,再复制两个存储投影需要的少量元数据,避免原先为清洗而深拷贝整份 DTO。预算随输入进入 blocking 压缩闭包,调用方取消不会提前释放仍存活的正文额度;压缩结束后原始 JSON 释放,压缩结果继续走原事务、审计和正文存储流程。 - -本地验证: - -- data contracts 226 项、PostgreSQL 231 项、data runtime 356 项、usage runtime 282 项普通测试通过,合计 1095 项;其中本轮新增 27 项,覆盖队列积压、并发 seed、预算拒绝与复制、token/图像计费事实、typed clear、legacy metadata、重试/DLQ,以及取消和 panic 后额度释放。另有 25 项需要外部条件的测试默认忽略。日志:`/tmp/aether-round6-data-tests.log`、`/tmp/aether-round6-runtime-tests.log`。最终时间采样和反向 DTO 转事件的所有权修正后,usage runtime 282 项再次全部通过:`/tmp/aether-round6-usage-final-tests.log`,不重复计入总数。 -- 隔离 PostgreSQL 14.17 的 5 项真实回归通过。完整审计测试现覆盖受预算管理的单条、普通 pending 批量及同 request 重复批量写入,四份正文读取一致,准备结束后只保留调用方原对象的额度,最终释放为 0;同时验证过期终态 no-op、辅助计数幂等和 typed `None` 清空。日志:`/tmp/aether-round6-postgres.K8Aq5W/`;临时数据库已停止。 -- 网关、隧道、loadtools 和 integration 的 binary/example 入口通过 `cargo check --locked`,日志:`/tmp/aether-round6-entrypoints-check.log`;修改文件格式及 diff 检查通过。最终 `cargo build --locked -p aether-gateway --bin aether-gateway` 通过,网关可执行文件已更新,日志:`/tmp/aether-round6-gateway-build.log`。全部测试与构建进程已退出;尚未部署,未重跑业务阶梯吞吐压测,未执行全工作区测试。 - -本轮边界: - -- 预算覆盖上述运行时链路持有的四份诊断 JSON 正文及副本,不是进程 RSS 上限。原始 Redis RESP、批次字段字符串及反序列化临时分配仍不受此额度约束;直接通过契约自行构造或反序列化的 DTO 默认不启用运行时预算。 -- seed 进入本链路前的 JSON/base64 解析、构建过程的临时正文复制、序列化/压缩结果和 SQL bind 缓冲未纳入。预算不足时的纯构建会使用调用线程 CPU;它保留计费兼容性,没有消除解析和估算开销。 -- 协议观察器及用量恢复缓冲、超大历史窗口增量聚合、完整请求优雅排空和目标环境长期压测仍待后续处理。第三轮吞吐数据不能作为本轮构建或生产容量结论。 - -## 第七轮修复状态 - -- **Redis 回复转移正文缓冲:** `XREADGROUP` 采用当前 redis crate 支持 owned conversion 的底层容器结构,避开 `StreamReadReply` 的借用转换;字段正文直接由 RESP `BulkString` 转成 `String`。`XAUTOCLAIM` 的消息正文、游标和删除 ID 同样使用所有权转移。保留 RESP2/RESP3、nil、重复字段覆盖、无效 UTF-8 和错误分类等既有解析语义,没有改变队列协议。 -- **减少队列处理副本:** Memory 队列读取直接遍历待交付条目,去掉全局队列锁内的整批临时正文克隆;队列、PEL 和调用方继续独立持有自己的数据。usage worker 在处理完一条消息后先释放原始字段,再等待 ACK/DELETE,避免已处理的大正文跨确认 I/O 继续存活。失败和死信路径仍保留所需原文到处理结束。 -- **流式用量恢复收敛保留字段:** Claude 的跨记录状态只累计 mapper 使用的 token 和缓存字段,以及非空用量出现标记,未知大字段不再随流长度累积,也不再每次复制完整累计对象。候选和 chunks 数组省略没有用量或 tier 信息的空元素,保留 Gemini 的首候选位置和倒序查找规则。完整且未超限的 SSE 记录直接借用当前输入切片,跨分片与超限记录继续沿用原 carry 和恢复规则。 -- **投影兼容修复:** 显式 `usage: null` / `usageMetadata: null` 保留字段存在性,避免错误回退到旧快照;独立 null 不新增清零信号,带其他非空用量的嵌套结构遵循原 mapper 的优先级。图片回复仅提取 `data/result` 数量以恢复 `request_count/image_count`,不复制图片正文。 - -本地验证: - -- runtime state 76 项、usage runtime 282 项回归通过,合计 358 项。Redis parser 的 7 项新增测试用正文原指针和容量断言验证缓冲转移,另有 3 项 Memory 所有权和队列生命周期回归。日志:`/tmp/aether-round7-queue-tests.log`。 -- 新增真实 Redis 大消息回归,两种协议各 24 条消息、每条约 512 KiB 正文,3 个消费者同时分批读取,再以最多 5 条重领和 ACK/DELETE;48 条消息原文逐字节一致,无重复交付到不同读取结果,最终 pending、lag 和 stream length 都为 0。日志:`/tmp/aether-round7-large-redis-test.log`。测试自建 Redis 已关闭;此项已包含在前述 76 项中,不重复计数。 -- 网关流式、stream pump 和空闲读取期限 186 项回归全部通过,日志:`/tmp/aether-round7-stream-final-tests.log`。包含本轮新增的 9 项投影与借用测试,覆盖原始 Claude 累计对象对照、256 条带不同未知大字段的长流、10000 个空数组元素、正文复制计数、CR/LF/CRLF 分片及上限边界、显式 null 的实际 mapper 对照,以及仅有图片数量的回复。连同队列侧共 544 项通过,本轮新增 20 项;首批 parser、真实 Redis 和流式聚焦测试不重复计数。 -- 修改文件格式及 diff 检查通过,最终 `cargo build --locked -p aether-gateway --bin aether-gateway` 通过,网关可执行文件已更新,日志:`/tmp/aether-round7-gateway-build.log`。所有测试、构建进程和临时 Redis 均已退出;尚未部署,未执行新的业务吞吐压测或全工作区测试。 - -本轮边界: - -- 原始 Redis 消费批次仍按配置的条数读取,不是字节预算。默认每批最多 128 条、最多 32 个 worker;本轮减少重复分配,没有限制任意大消息或整体批次的最大驻留字节,也没有通过丢弃账务消息缩小批次。当前同 worker 的 read/reclaim 已串行,已处理条目原本就逐条释放。 -- 流式恢复仍保留必要的跨分片单记录,Basic 5 MiB / Full 64 MiB 的原限制未改变;多行 `data:` 拼接、JSON 解码临时值、非空用量数组和主协议观察器不在全局正文预算内。不能因减少诊断副本而直接停用计费恢复。 -- 主协议解析器的累计文本、原始队列批次字节控制、超大窗口增量聚合、完整请求优雅排空及目标环境长期压测仍待后续处理。 - -## 第八轮修复状态 - -- **主协议用量观察器取消正文累计:** `StreamingStandardTerminalObserver` 为 OpenAI Chat、Responses(包括 compact)及 Gemini 选用现有 provider parser 的终态观察模式。协议转换仍使用完整模式;观察器沿用同一事件分类和用量解析,只保留摘要需要的状态。Claude 当前没有正文累计,独立图片观察器也继续使用原实现。 -- **长流正文和工具参数:** Responses 不再保存文本、双份推理文本、工具参数、工具结果和图片项正文;保留工具索引、名称及 namespace 校验,它们影响未知事件计数和工具调用结束原因。Chat 不再等待迟到的工具名称或 ID 而持续缓存参数。Gemini 不再保存累计文本、推理、签名、媒体、工具参数和结果,只记录是否见过工具调用以保留结束原因。 -- **opaque 项去重:** Responses 观察模式将未知扩展项的去重键改为固定 32 字节 SHA-256 摘要,按原始键的相同字节增量计算,避免加密内容或整个序列化项成为常驻键。默认转换和客户端 emitter 的原键及完整输出保持不变。 - -本地验证: - -- `aether-ai-formats` 全部 922 项测试通过,包含本轮新增 16 项。逐个输入前缀及提前 EOF 对比完整解析器与观察模式的摘要,覆盖身份时点、用量、显式零、tier、错误、未知项去重、namespace、工具调用和 SSE / WebSocket 结构化入口。原有格式转换、会话历史、图片及同步转流回归均通过。日志:`/tmp/aether-round8-formats-final-tests.log`。 -- 长流回归在 2048 轮 1 KiB 文本、推理及工具参数输入后,直接断言 Responses 的正文缓冲容量仍为 0、Chat 工具缓冲为空;Gemini 在 32 轮多种 16 KiB 字段输入后所有正文状态 map 仍为空,并与完整模式核对终态帧。大 completed 项与 opaque 键的摘要字节、去重语义另有测试。此处验证内容保留行为,没有测量生产 RSS 或吞吐上限。 -- 网关流式、stream pump 和读取期限 186 项通过;Responses WebSocket 会话、上游和观察器 209 项通过。连同格式 crate 共 1317 项通过。日志:`/tmp/aether-round8-gateway-stream-tests.log`、`/tmp/aether-round8-gateway-websocket-final-tests.log`。WebSocket 回归直接运行同一份已编译测试程序;其间因现有 build script 监听 worktree 中不存在的 `.git/HEAD` 而触发的一次重复 Cargo 编译已主动停止,没有把中止当成测试通过。 -- 修改文件格式和 diff 检查通过,最终 `cargo build --locked -p aether-gateway --bin aether-gateway` 通过,日志:`/tmp/aether-round8-gateway-build.log`。本机内存压力较高,网关测试目标编译耗时 11 分 21 秒、最终构建 8 分 54 秒;这些是本地编译耗时,不是业务延迟。全部测试、构建进程已退出,未遗留本轮临时服务。尚未部署,未执行新的业务吞吐压测或全工作区测试。 - -本轮边界与后续: - -- Responses 的工具身份和 opaque 摘要集合仍按不同逻辑项数增长;单条 JSON 解码、未知事件错误载荷及部分 Gemini 分类 helper 仍有临时分配。完整格式转换和会话历史依赖的正文仍保留,本轮不宣称整个解析器或进程具有硬性 RSS 上限。 -- Redis 原始批次仍没有硬性字节上限。现有 redis 连接的超时或 future 取消不会保证后台立即停止接收已发命令的回复,仅读取后套预算或减少 COUNT 不能解决任意大消息。建议下一步先为新生产的完整队列 envelope 实施精确序列化字节上限,超限诊断降级须保留计费事实并沿现有失败路径重试;旧消息和 PEL 仍需兼容排空。真正的接收预算还需要读取前预留及受控连接/解码器,不能直接依赖当前 usage body blob 表作为入队旁路,该表依赖已存在的 usage 父记录。 -- 超大窗口增量聚合、完整请求优雅排空及目标环境长期压测继续待处理。尚未部署。 - -## 第九轮修复状态 - -- **新增队列消息的完整字节上限:** `AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES` 默认 1 MiB,启用 usage runtime 时显式 `0` 非法。`UsageQueue::enqueue` 在发送 Redis 命令前,以有界 writer 编码完整 v1 JSON envelope,包含 UTF-8、转义、metadata、正文、headers 及其他字段;不会先生成无限制的完整 JSON 字符串再检查长度。普通消息保持原格式,原公开编码接口及历史消息解码继续兼容。 -- **诊断降级保留计费语义:** 完整消息超限后,先借用检查去掉四份正文和四份 headers 的核心字段大小,核心可容纳才克隆 metadata 并生成诊断投影;复用同一字节缓冲。保留 token、费用、显式零、错误存在性、身份、时间、终态、正文引用、预留 token 和计费维度。按完整 v1 消费者规则保留请求档位、推理参数、响应实际档位及缓存 TTL,并标记被移除的正文为 `Truncated`。显式 `None`、`Disabled`、`Unavailable` 不改为可回退的状态;JSON null 与非对象正文分别按旧解码和权威规则处理。 -- **无法安全编码时的失败路径:** 核心仍超限,或去掉正文无法保留原缓存 TTL 计费语义时,返回 `InvalidInput`。终态沿现有有并发限制的数据库路径使用原事件回退;数据库不可用、受压或写入失败时明确返回 `Failed`,保留 first-byte 状态,不误报已入队或已缓冲。这类输入错误不打开 Redis 熔断,也不会无限重试。重试接收前再次校验,覆盖主路径已熔断或入队槽耗尽的旁路;重试 worker 也会终止单条永久失败并继续处理后续条目。 -- **指标与临时分配:** 导出队列 payload 上限、诊断降级、编码拒绝及永久重试失败计数。payload 计数是进程级编码尝试,包含入队与重试预校验,不是唯一事件数。超长 tier/reasoning 字符串先检查已有的 64 字节限制,再执行大小写规范化,避免明知非法仍复制整个字符串。 - -本地验证: - -- 用量模块全部 298 项通过,包含本轮新增的 9 项编码、6 项失败路径及 1 项配置回归。精确覆盖 UTF-8/转义字节边界、编码缓冲复用、字段透传、源事件不变、完整 v1 消费者对照,以及超限后的数据库回退、first-byte 保留、熔断/准入旁路拒绝和同重试分片继续排空。日志:`/tmp/aether-round9-usage-final-tests.log`。 -- 计费模块全部 87 项、数据契约全部 229 项通过。本轮新增 7 项真实队列读回计费对照与 3 项超长字段规范化回归;对照覆盖请求档位、缓存 TTL、显式零、未知价格、错误/取消、图片矩阵维度,以及 15 组非对象/null 正文组合。计费结果以原完整 v1 消息经旧解码路径后的行为为基线。连同用量模块共 614 项通过,不重复计入首批验证。日志:`/tmp/aether-round9-billing-contracts-final-tests.log`。 -- 网关指标、工作区模块边界、流式链路及 Responses WebSocket 共 438 项通过;网关入口配置全部 62 项通过,包括本轮新增的 payload 配置与指标回归。连同核心模块共 1114 项通过,本轮新增 28 项测试。直接复用本次构建生成的测试程序执行,日志:`/tmp/aether-round9-gateway-lib-tests.log`、`/tmp/aether-round9-gateway-main-tests.log`。 -- `cargo build --locked -j 1 -p aether-gateway --all-targets` 通过,最终网关可执行文件已更新;一次构建同时生成普通程序与测试目标,本地耗时 25 分 20 秒。日志:`/tmp/aether-round9-gateway-build.log`,产物清单:`/tmp/aether-round9-gateway-artifacts.jsonl`。修改文件格式及 diff 检查通过,所有本轮测试和构建进程均已退出;未创建临时服务。尚未部署,未重新执行业务吞吐压测或全工作区测试。 - -本轮边界: - -- 本次限制针对新生产的单条 JSON payload,不包含 RESP 外壳、完整读取批次、历史消息、PEL、DLQ 或进程总 RSS。原始 JSON 树、headers、metadata 的存活内存及解析临时值不因此获得统一字节预算;默认 128 条批次、32 个 worker 仍可能同时接收很多消息。真正的接收预算仍需要读取前预留和受控连接/解码器。 -- 对过大的原 metadata 采用保守拒绝,避免为判断最终能否缩小而先深拷贝整个对象;即使后续规范化理论上可使其变小,也使用原事件回退。模型分类 helper 的超长输入临时分配仍需后续审查,本轮不宣称所有计费提取都有硬性内存上限。 -- 没有新增持久化超限旁路。若消息无法编码且受限数据库回退也失败,用量落库会失败,并通过日志和指标暴露;不能把有限本地缓冲称为可靠落盘。超大窗口增量聚合、完整请求优雅排空及目标环境长期压测仍待处理。尚未部署。 - -## 第十轮修复状态 - -- **阻塞读取改为独占连接:** 原 blocking stream lane 通过轮询复用 `ConnectionManager`,快 worker 再次读取时可能命中其他 worker 正在执行 `BLOCK` 的连接,造成队头阻塞;取消调用 future 后,后台 driver 仍可能继续执行旧命令。现改为有固定容量和信号量的独占池,先取得空闲槽位再发命令,等待者取消不会影响现有读取。连接和未 spawn 的 driver 由同一查询持有,取消、超时及解析失败一起丢弃,下一次使用该槽位时重建;完整解析成功才回池复用。保留原 lane 数量、认证、数据库、RESP 配置及超时/延迟统计。 -- **积压重领继续扫描游标:** 原运行时丢弃 `XAUTOCLAIM` 返回的下一扫描位置,worker 每次从 `0-0` 开始。当大量近期活动的待确认消息占据前段,Redis 单次扫描可能返回空页,后段过期消息长期得不到重领。新增兼容的分页接口,保留下一位置和已删除消息 ID;worker 在成功响应后推进游标,空页和删除页也推进,读取错误保持原位置,扫描结束再从头开始。写入失败的消息不提前确认,仍留在 PEL,回绕或 worker 重启后可再次重领。原返回消息列表的公开接口和旧队列实现继续兼容。 -- **内存队列并发入队顺序:** 将序号分配移入队列插入的同一个锁范围。此前等待插入的生产者可能先取得较小 ID,其他生产者先插入并被消费后,较小 ID 会被读取游标永久跳过;现在 ID 顺序与实际插入顺序一致。内存后端同时实现重领分页,按数值序号推进。 - -本地验证: - -- `cargo check --locked -j 1 -p aether-runtime-state -p aether-usage-runtime` 通过。日志:`/tmp/aether-round10-core-check.log`。 -- 运行时状态全部 84 项、用量模块全部 300 项测试通过,共 384 项,包含本轮新增 10 项:连接池 2 项、内存队列 2 项、worker 游标 2 项、真实 Redis 4 项。覆盖连接容量、等待取消、多任务竞争、锁等待下的 ID 顺序、空页/删除页、读取失败重试、写入失败后回绕及原有记账流程。日志:`/tmp/aether-round10-queue-tests.log`。 -- 真实 Redis 测试另以 `--nocapture` 运行同一份测试程序,确认 6 次隔离实例就绪、没有跳过,4 项全部通过,不重复计入上述 384 项。使用本机 Redis、ACL 认证及数据库 7,覆盖 RESP2/RESP3、满池等待不发新命令、其他 lane 继续服务、快消费者复用空闲连接、取消/超时后服务端旧连接消失、重连保留认证/数据库,以及空扫描页后的 PEL 尾部和删除项恢复。日志:`/tmp/aether-round10-redis-receive-tests.log`。 -- 最终 `cargo build --locked -j 1 -p aether-gateway --bin aether-gateway` 在运行约 18 分 36 秒后因本机资源压力主动停止,退出码 143,不计为构建通过;停止前没有编译错误,日志:`/tmp/aether-round10-gateway-build.log`。期间清理了三份当前构建未使用的旧增量缓存,将可用磁盘从约 3 GiB 恢复至 7 GiB,但内存压力仍使编译持续缓慢。本轮没有修改网关入口、配置或指标,新增逻辑已在上述核心模块中验证;未重复编译网关测试目标,现有网关程序仍是上一轮产物。 -- 修改文件格式和 diff 检查通过。本轮测试、编译和临时 Redis 进程均已退出。尚未部署,未重新执行业务吞吐压测或全工作区测试;资源允许时仍需补跑最终网关构建。 - -本轮边界: - -- 本轮修复连接占用和积压恢复,不是完整读取批次的字节预算。历史超大消息、RESP 解码、默认 128 条批次和多个 worker 的总驻留内存仍需后续控制;上一轮新消息 1 MiB 上限保持有效。 -- 连接生命周期的取消控制目前仅用于阻塞 `XREADGROUP`。非阻塞 stream 命令及 `XAUTOCLAIM` 仍沿用原共享连接;关闭连接不能撤销 Redis 已执行的读取,已进入 PEL 的消息仍依赖重领。内存后端分页仍扫描并排序符合条件的待确认条目,本轮未建立该扫描的硬性内存上限。 -- 超大窗口增量聚合、完整请求优雅排空及目标环境长期压测继续待处理。尚未部署,测试结果不能用来推断生产 RPM 上限。 - -## 第十一轮修复状态 - -- **读取批次的共享预留:** 新增进程级 usage worker payload 预留,默认总量 128 MiB、单批目标 8 MiB。根据当前 `queue_payload_max_bytes` 推导实际 `COUNT`,默认由 128 条降到最多 8 条;读取与重领、所有 worker 和自动扩容后的新 worker 共用同一份额度。许可在发命令前取得,并保留到原始字段处理、记账及 ACK 完成;取消、失败和空响应释放,预算不足时在原任务内等待,不新增缓存任务。收到消息后按字段值实际长度缩减多余预留。 -- **兼容历史消息与配置:** `AETHER_USAGE_QUEUE_READ_PAYLOAD_BUDGET_BYTES`、`AETHER_USAGE_QUEUE_READ_BATCH_PAYLOAD_BYTES` 分别控制总额与单批目标;0/非法值回退默认,极大值收敛到约 4 GiB 的有效额度,单批目标不超过总额。单条配置上限大于总额时明确返回配置错误,避免等待永远拿不到的许可。历史消息或其他生产者的大消息继续原记账流程,不在持有部分额度时等待追加,也不因超估算而删除或死信。 -- **重领连接与停止:** `XAUTOCLAIM` 改用上一轮的独占连接池,完整解析成功才回收连接。取消/超时会同时释放查询和 driver,避免归还读取预留后旧命令仍在后台接收。等待池容量也纳入原命令期限,指标归入实际使用的 `blocking_stream` lane;worker 取得重领响应前可被 shutdown 取消,取得响应后完成原处理与确认。读取与重领之间不嵌套持有池连接。 -- **扩缩容与观测:** worker 上报实际请求的 `COUNT`,防止读满 8 条却按 128 条误判为未满、压制扩容。新增 8 个 `usage_runtime_queue_read_*` 指标,涵盖总额、单批目标、当前预留、等待者、等待次数、累计字段字节及超估算条目/批次。累计字段字节包含字段名和值;预留及超估算判定使用值长度,合法 payload 恰好达到上限不会因字段名 `payload` 多出 7 字节而被误报。次数包含再次重领,不是唯一事件数。 -- **计费查询失败的恢复语义:** 原 worker 吞掉计费补全错误后继续写入/结算;暂时的数据库超时可能被后续“缺少实际费用”的永久错误覆盖,导致错误死信和 ACK。现在先传播原始错误,失败条目留在 PEL,后续重领再尝试;真正返回成功的无价格事件沿用原行为。三个直接落库入口也统一在补全失败时停止写入,不能误报已持久化或完成有序终态;已有受限数据库回退本来就正确停止,继续沿用。永久错误归档前先释放已解码事件,减少与原字段、死信编码的同时持有。 - -本地验证: - -- 运行时状态全部 87 项测试通过,包含本轮新增 3 项真实 Redis 重领回归。日志:`/tmp/aether-round11-state-tests.log`。 -- 用量模块最终全部 321 项通过,包含本轮新增 9 项预留、8 项 worker、4 项直接落库回归;连同运行时状态共 408 项,本轮新增 24 项。覆盖跨 Queue/worker 共享额度、16 个并发任务竞争、极大配置不溢出或永久等待、读/重领实际 COUNT、慢写/ACK 持有、错误和停止释放、历史超估算消息继续处理,以及价格查询超时不提前写库或确认、后续成功尝试使用准确费用。日志:`/tmp/aether-round11-usage-final-tests.log`。直接落库回归中的恢复是后续显式调用,不是新增自动重试。 -- 直接运行本轮已编译的真实 Redis 接收测试程序,7 项全部通过,确认 9 次隔离实例就绪、没有跳过;不重复计入上述测试数。RESP2/RESP3、ACL、数据库 7 下验证读/重领取消和超时、服务端旧连接释放、PEL/游标/删除项恢复,以及共池时等待期限、等待取消、释放单个槽位后继续读取和重领。日志:`/tmp/aether-round11-redis-receive-tests.log`。 -- 修改文件格式与 diff 检查通过;网关 `cargo check --locked -j 1 -p aether-gateway --bin aether-gateway` 通过,耗时 3 分 14 秒,日志:`/tmp/aether-round11-gateway-check.log`。本轮测试、检查和临时 Redis 进程均已退出。本轮没有重复执行上一轮因本机资源压力中止的完整代码生成与链接,最终网关程序仍需在资源允许时构建;未部署,未执行业务 RPM 压测或全工作区测试。 - -本轮边界: - -- 这是按当前生产配置估算的逻辑 payload 预留,不是网络接收字节或进程 RSS 的硬上限。滚动发布、其他实例使用更高上限、历史消息和直接写入额外字段都可超过估算。字段结构、字符串容量、Redis RESP 解码、连接缓冲高水位、解码 JSON 及死信序列化不由此获得硬上限;旧公开 Vec 读取接口保持兼容,不携带处理阶段许可。 -- 死信仍先追加再确认源消息,异常重试可能重复归档;历史大消息的死信 JSON 编码也没有独立硬字节预算。后续需要按源消息身份幂等的转移及受控超大消息恢复,不能以截掉原始账务字段或提前 ACK 代替。 -- 直接落库失败不会因此新增持久化重试渠道。超大窗口增量聚合、完整请求优雅排空及目标环境 RPM 压测仍待处理;尚未部署。 - -## 第十二轮修复状态 - -- **死信原子转移:** 内置 Redis 后端以一次 Lua 调用检查指定消费组的精确 PEL 身份,再追加完整死信、ACK 并删除源 ID;并发重领或提交成功但响应丢失后的重复调用不再次追加。Memory 后端在同一队列锁内完成转移,序号也在锁内分配。新增可选 trait 接口,默认返回 `None` 且无副作用;旧外部实现仍可使用原来的非原子追加后确认,内置转移报错不会降级为非原子写入。公开 `push_dead_letter` 保留原追加行为及 `{entry_id, fields, error}` JSON 格式。 -- **先检查,再写入:** 校验完整 canonical `u64-u64` ID、不同的源与目标键及非空字段;Redis 在首写前检查 PEL、目标类型和 XADD/XACK/XDEL 权限,避免可预见的脚本错误导致部分归档。Lua 不将 ID 转为浮点数,保留大整数精度。正文已被 trim 但 PEL 仍存在时,可使用调用者持有的完整字段归档。操作使用既有独占连接和 owned driver,等待池容量也计入超时,完整解析成功才回池;取消不能撤销已经完成的 Redis 命令,重试依靠 PEL 状态避免重复。 -- **独立的编码预留:** 新增 `AETHER_USAGE_DLQ_ENCODING_BUDGET_BYTES`,默认 64 MiB,及 `AETHER_USAGE_DLQ_ENCODING_MAX_JOBS`,默认 4。按原文字符串长度、JSON 最坏 6 倍转义及字段结构分隔符一次预留逻辑字节,使用 checked 运算和有界 writer;预留成功后才复制兼容接口输入或启动后台编码。worker 直接移动现有原始字段,永久记录错误时先释放解码事件。额度不足或单条过大立即失败,原消息保留在 PEL,不截断账务字段。后台任务取消等待后仍持有自己的许可,许可随编码结果保留到队列写入完成;与读取预留独立,避免持有部分读取额度再等待追加。 -- **worker 状态与指标:** 成功原子转移直接使用实际 ACK 数并报告一次死信,不再加入批次 ACK;返回 `NotPending` 不报告已归档,也不额外删除源消息。普通记录与旧后端追加仍批量确认,改为报告实际返回的 ACK 数。某条消息的存储转移失败时,只确认此前已成功处理的前缀,失败条目及尚未处理的后缀等待重领。编码预算拒绝及编码失败单独返回延期状态,保留坏消息并继续同批其他条目,只确认成功项,批次末尾仍报告失败;避免永远超过总预算的坏消息在每次重领时持续挡住正常账务。归档成功日志移到写入返回之后。新增 7 个 `usage_runtime_dlq_encoding_*` 指标:总额、最大/当前任务、预留字节、容量拒绝、超限拒绝及编码次数;次数包括重复尝试,编码成功不等于归档成功。 - -本地验证: - -- `cargo test --locked -j 1 -p aether-runtime-state` 全部 102 项通过;本轮新增 15 项,包括 8 项 Memory 转移、6 项真实 Redis 和 1 项返回解析验证。测试编译耗时 13.62 秒,执行 2.45 秒;日志:`/tmp/aether-round12-state-tests.log`。 -- `cargo test --locked -j 1 -p aether-usage-runtime` 全部 341 项通过;本轮新增 20 项,包括 8 项编码预算、3 项队列接线、8 项 worker、1 项配置验证。测试编译耗时 30.79 秒,执行 1.16 秒;日志:`/tmp/aether-round12-usage-tests.log`。连同状态模块共 443 项通过,本轮新增 35 项,无编译警告。覆盖完整原文及所有转义、精确上界和溢出、并发饱和、未 poll/后台运行/存储等待取消、panic 释放、原子失败不回退、ACK 实际计数、成功前缀确认、超大坏消息延期后正常后缀继续、后续调高预算恢复,以及旧后端和公开追加兼容。 -- 直接运行已编译的 `redis_dead_letter_transfer_ --nocapture`,6 项全部通过,确认 RESP2/RESP3、ACL 认证及数据库 5 下共 8 次隔离实例就绪,没有跳过;不重复计入上述 443 项。覆盖 16 个并发调用只追加一份、丢弃成功结果后重试、分别拒绝 XADD/XACK/XDEL 时无部分写且修复后可恢复、错误目标类型/消费组/ID/同键/空字段、正文 trim 后仍保留 PEL、超过 Lua 整数精度的 ID。日志:`/tmp/aether-round12-redis-transfer-tests.log`。取消/超时连接释放同时由状态模块中的前两轮 owned-driver 回归覆盖。 -- `cargo check --locked -j 1 -p aether-gateway --bin aether-gateway` 通过,耗时 2 分 23 秒,无警告;日志:`/tmp/aether-round12-gateway-check.log`。修改文件格式及 diff 检查通过;测试、检查和本轮临时 Redis 进程均已退出,现有服务未变更。本轮未重复执行前轮因资源压力中止的完整代码生成和链接,网关可执行产物仍需后续完整构建;未部署,未进行目标环境业务 RPM 压测或全工作区测试。 - -本轮边界: - -- 原子性和重复抑制按同一源 stream、消费组及源 ID 生效,不是永久去重索引。`NotPending` 只说明 PEL 不存在,外部 ACK、trim/delete、消费组销毁重建及其他消费者都可能改变这个状态,不能据此推断曾经归档。Redis XDEL 后其他消费组仍可能保留 PEL,Memory 沿用删除全部组 PEL 的既有语义;本轮不提供跨消费组的全局归档去重。 -- Redis 转移要求 7+ 的 `redis.acl_check_cmd` 和 `EVAL/TYPE/XPENDING/XADD/XACK/XDEL` 权限。Lua 错误本身没有回滚能力;脚本预检当前已知可失败的前置条件,不替代 Redis 持久化及故障恢复保证。Cluster 两键必须同 slot,当前默认 stream 键没有自动改名或迁移;缺能力、权限或跨 slot 失败会保留源消息,不能自动降级。 -- 编码预算覆盖任务所持原文长度与 JSON 上界,包含存储等待阶段,仍不是进程 RSS 或网络缓冲硬上限。字段容器、字符串多余容量、内存后端持久副本、Redis Cmd/packed-command/连接缓冲副本不计入;公开追加及旧后端可能沿用共享 driver。0/非法环境值回退默认,bytes 最大约 4 GiB,jobs 最大 128。保守 6 倍估算会拒绝实际编码较小的超大原文;这类存量需调高额度后重试或受控恢复,本轮未新增自动大消息通道。 -- 已提交但响应丢失时可以避免重复归档,当前进程的成功计数可能少计;普通 ACK 成功后 XDEL 失败也可能少计 ACK,指标不是持久化账本。死信总量及保留期限仍需运营管理,本轮没有静默修剪死信。完整请求优雅排空、超大窗口增量聚合与目标环境业务 RPM 压测仍待处理;尚未部署。 - -## 第十三轮修复状态 - -- **TCP 层先限量:** 原入口每次 accept 后都创建独立连接任务,HTTP 请求限流尚未生效的握手及空闲 keep-alive 不受请求 gate 保护。新增 frontdoor `HttpConnectionBudget`,二进制入口的全部监听分片和指标使用同一个 Arc;accept 后立即尝试取得许可,满额直接关闭刚接入的 socket 并让出执行,不创建 HTTP 任务或额度等待队列。先 accept 再准入,避免无流量的 reuseport 分片预占许可、饿住有流量的分片。 -- **许可跟随 socket:** 将许可放入底层 AsyncRead/AsyncWrite 包装,完整透传读、写、flush、shutdown 及 vectored write。socket 先释放、许可随后归还;HTTP/1 upgrade 会携带整个 IO 包装,所以连接 future 返回后 WebSocket 仍占一个许可,直到升级连接释放。HTTP/2 同一 TCP 内多路流共用一个许可,原 HTTP 请求和 WebSocket 会话 gate 保持独立;取消、解析失败及首个请求头超时也会释放底层 IO。 -- **accept 错误恢复:** 原 `listener.accept().await?` 会退出任意出错的监听任务,主入口随后停止其他监听分片。改用共同的 accept helper:ConnectionRefused/Aborted/Reset 重试,其他错误记录后退避一秒再试,资源耗尽不会触发无等待的重复 accept。兼容 `serve_tcp` 入口也使用相同预算和恢复逻辑。 -- **容量与观测:** 新增 `AETHER_GATEWAY_MAX_HTTP_CONNECTIONS` / `--max-http-connections`。未配置或 0 时按请求上限与 WebSocket 上限之和自动推导;自动和显式配置都限制在 1 至 65536,已知 FD soft limit 时再限制为 `max(1, (FD - 256) / 2)`。启动日志记录实际值,新增 `gateway_http_connections_limit/in_flight/high_watermark/rejected_total/accept_errors_total` 五项指标。 - -本地验证: - -- `cargo test --locked -j 1 -p aether-gateway-frontdoor` 全部 43 项通过,无警告,包含本轮新增 9 项连接回归。覆盖默认/显式/0/FD 极值、不溢出的容量推导、IO 先于许可释放、拒绝立即消费 socket、读写及 vectored write/半关闭透传、两个真实 listener 共享 limit=1 后恢复、首次 poll 前取消、真实 HTTP/1 首头超时和解析失败、101 upgrade 后 HTTP future 已结束但升级 echo 仍持许可、真实 HTTP/2 两条并行请求共用一个连接许可,以及注入 accept 错误后继续服务、资源错误每次精确退避一秒。日志:`/tmp/aether-round13-frontdoor-tests.log`。暂停时钟的退避测试未真实耗尽进程 FD。 -- `cargo check --locked -j 1 -p aether-gateway --all-targets` 最终通过,耗时 3 分 42 秒,无警告,覆盖主程序、库及测试/示例等目标;日志:`/tmp/aether-round13-gateway-final-check.log`。本轮另新增 2 项 AppState 共享预算和指标接线测试,已随全部目标完成编译检查,但没有重新生成及执行大型网关测试程序,不计入上述 43 项执行通过数。初次检查发现新增测试误用了不存在的 `for_tests` 方法,已按现有测试模式修正为 `AppState::new()`;初次退出码 101,不计为通过。 -- 修改文件格式及 diff 检查通过。frontdoor 测试新增三个已锁定版本的开发依赖引用,Cargo.lock 仅增加对应依赖名,没有升级依赖版本。本轮测试、检查及临时 TCP 连接/任务均已退出,现有服务未变更;没有重复进行网关完整代码生成和链接,也没有部署或执行业务 RPM 压测。 - -本轮边界与后续发现: - -- 这是已准入的入站 socket 数量上限,不是 HTTP/2 请求任务、全部 FD 或进程 RSS 上限。每个监听分片在 accept 与同步准入之间可短暂持有一个待判定 socket,kernel backlog、上游、Redis、数据库及其他入口不计入。容量规划仍需实测;健康检查使用同一入口,也可能在连接满额时被拒绝。 -- 满额发生在 HTTP 解析前,客户端看到连接关闭而不是 429/503;客户端和反向代理的重试应有退避。成功请求结束后 keep-alive 连接继续占额度,直到连接释放;本轮没有新增空闲连接回收、强制流期限或完整请求优雅排空。现有超时及流式传输语义保持原样。 -- 公共兼容 `serve_tcp` 每次调用使用独立预算,默认 4096,可用同名环境变量覆盖,最大 65536;它没有二进制入口的请求/WS 容量配置和 FD 探测,也未将预算绑定到其默认 AppState 的五项指标。二进制入口拥有 FD 约束、跨分片共享及指标接线。 -- 另确认 usage 窗口 Lua 每次准入全量清理过期成员,集中到期可能阻塞 Redis。不能直接换成分批删除加当前窗口 ZCOUNT:合法并发请求的时间参数可能乱序到达,后来的较早 cutoff 会重新计入此前逻辑上已过期但未物理删除的成员。保持精确额度需配套单调清理水位等状态设计;本轮没有修改这段逻辑。相同终态的事件级及 stored-record 级成本预留协调可能重复取得 PostgreSQL 用户行锁,后续可研究携带匹配身份的首次结果,不能无条件删除任一调用。 -- usage 独立 runtime 的本地重试渠道仍缺少完整停止与排空生命周期;当前数量有界,但进程退出前未入 Redis 的缓冲终态仍需统一处理。本轮没有修改停机流程、迁移架构或部署,业务 RPM 与长期排空压测仍待执行。 - -## 第十四轮修复状态 - -- **消除同次写入的重复成本协调:** worker 和直接落库原来先按事件协调成本预留,upsert 返回后又按存储记录协调一次,正常终态重复获取 PostgreSQL 用户行及预留行锁。现在首次调用返回的持久化结果同时匹配 request ID、用户、服务端预留 token、实际费用单位、终态和完成秒数时,携带一个仅限本次写入的内部结果;存储记录再次匹配全部字段才省去第二次协调。钱包结算、原用户级锁、费用检查及幂等性继续执行。 -- **保留冲突和重试语义:** 首次返回 `None`、返回其他终态或身份、upsert 后记录变化,都继续按存储记录重新协调。首次协调出错仍在 upsert 前停止;upsert 失败后的新尝试重新协调,不跨请求或重试缓存结果。公开协调与结算 API 签名不变;没有更改数据库表或放宽账务条件。 -- **修正验证工具:** PostgreSQL 结算基线补齐有余额的钱包及所属用户,并核对结算记录、快照、outbox、钱包消费和供应商累计值,检查失败返回非零退出码。此前无钱包的全量拒绝不能算吞吐成功。网关夹具补齐启动流程会创建的默认路由,通过 testkit 对象读取受保护的指标,缺失必要指标直接报错;容量报告保留 HTTP 状态和错误样本。探针新增排空基线与最终必要指标字段,判定阈值不变。网关阶梯夹具使用与主程序相同的 8 MiB Tokio worker 栈,避免 debug 路由调用链在默认小栈上溢出。 -- **隧道夹具按当前入口运行:** 仅在 `testkit` 功能下增加适配入口,将测试请求交给现有网关签名验证、正文完整性校验和临时 spool 流程,再进入内部 relay;没有开放跳过鉴权的 HTTP 路由。每条压力请求使用独立签名和 nonce。每个档位先检查合法签名成功并返回完整正文,无签名、篡改正文及重放均被拒绝;模拟节点通过并发计时等待响应,避免旧夹具在单个 WebSocket 读循环逐条 sleep 造成串行瓶颈。每个档位结束后显式取消并等待模拟节点任务。 - -本地验证: - -- `cargo test --locked -j1 -p aether-usage-runtime`:349 项全部通过,本轮新增 8 项,包含多字段不匹配矩阵、失败/取消/零费用、失败重试、256 个同用户并发写入和 32 次重复投递。重复投递使用真实 Memory 结算仓库,余额只扣一次。日志:`/tmp/aether-round14-usage-tests.log`。`gateway_pressure_probe` 既有 21 项测试全部通过,日志:`/tmp/aether-round14-probe-tests.log`。 -- 本轮已成功完整构建并链接网关、容量基线、结算基线和压力探针,补上第十至十三轮只做编译检查而未更新可执行文件的验证。主要日志:`/tmp/aether-round14-full-build.log`、`/tmp/aether-round14-final-harness-build.log`;无编译警告。 -- 主网关真实 TCP 冒烟:2 个监听分片共享连接上限 4,保持 4 条空闲连接后,另外 32 条连接在共 7 ms 内被关闭;释放后健康检查恢复 200,认证指标确认 high-watermark=4、rejected=32。网关正常退出,日志:`/tmp/aether-round14-connection-smoke.log`。 -- 隔离 PostgreSQL 14 结算热点:同一用户、同一钱包,100 并发完成 2000 笔真实结算,0 失败;2000 条 usage、结算快照及已处理 outbox 完整,待处理为 0,钱包消费与供应商费用均为 2.00 USD。耗时 2332 ms,857 次/秒,P95=251 ms;采样锁等待最高 62 个、最长 549 ms,同一钱包仍需串行扣费。报告:`/tmp/aether-round14-postgres-settlement-final.json`。 -- 网关同步/流式、执行运行时同步/流式和隧道共 5 个阶梯场景,在 8/32/128/256 的 gate 上限各执行 8 倍请求,共 16928 条全部 HTTP 200,无拒绝或读取失败。前四场景各 3392 条,隧道共 3360 条,隧道并发为上限减 1,因为 WebSocket 会话占一个许可。最终前四场景在途归零,隧道采样时只剩该会话,high-watermark 均达到对应上限。每个档位的签名、正文和重放预检通过。报告:`/tmp/aether-round14-capacity-final.json`;最终构建日志:`/tmp/aether-round14-capacity-auth-final-build.log`。这些场景是 Memory 仓库/模拟节点功能和容量验证,与下面真实数据库业务压测分开解读。 -- 主网关 + 隔离 PostgreSQL/Redis + 本地模拟上游,8 个 API key、4 个网关 Tokio worker、2 个监听分片、HTTP 请求上限 256、TCP 上限 512、数据库池上限 24,执行以下约 2 秒的流式请求,要求完整读取且出现 SSE `[DONE]`: - -| 并发 | 请求数 | 成功 / 失败 | 实测 RPS | 首字节 P95 | 完整响应 P95 | 排空判定耗时 | -| --- | --- | --- | --- | --- | --- | --- | -| 16 | 128 | 128 / 0 | 8.01 | 123 ms | 2062 ms | 9.93 s | -| 64 | 512 | 512 / 0 | 32.09 | 69 ms | 2046 ms | 9.86 s | -| 128 | 1024 | 1024 / 0 | 57.34 | 106 ms | 2074 ms | 119.21 s | - -业务压测报告目录:`/tmp/aether-round14-pressure.UYCeSq`。全部 1664 个请求为 HTTP 200;SQL 验证全部 completed,input/output/total tokens 分别为 1664/33280/34944,没有缺失首字节时间或单条 token 不匹配。所有档位必要排空指标齐全并通过连续安静期检查,最终用量队列、PEL、DLQ、outbox、请求在途和数据库锁等待均为 0。128 并发档网关 RSS 峰值约 190 MiB、末值约 147 MiB,FD 从峰值 338 降至 62,Tokio 活跃任务降至 85;没有要求 RSS 或常驻后台任务归零。 - -本轮边界: - -- 本地 debug 构建、模拟上游和短时压测不能推导生产 RPM 上限,也没有同环境修复前后的性能对照。模拟上游费用为 0,非零钱包金额另由上述 PostgreSQL 热点及 Memory 幂等测试验证。临时 PostgreSQL 夹具关闭 fsync、同步提交及 full-page writes,热点吞吐数不代表开启生产持久化后的性能。 -- 阶梯夹具使用 75 ms 模拟同步/隧道响应或 3 次 25 ms 流式间隔。256 并发时网关同步/流式 P95 分别为 770/808 ms,超过该夹具的 300 ms 延迟预算,吞吐从 128 并发的 596/754 RPS 降至 537/524 RPS;全部请求成功不等于此档位容量合格。执行运行时 P95 为 104/96 ms,隧道为 115 ms。需在目标环境定位网关层 CPU、调度及后台写入开销后决定并发上限,不应直接按本地峰值放大配置。 -- 128 并发后的完整排空约需两分钟;此前 45 秒检查及一次 120 秒检查未通过,不能作为快速回收的证据。当前复测确认最终回收,但仍需目标环境下长期压力、连接空闲回收和停机排空验证,不能归因为已确认的泄漏或宣称完全消除积压风险。 -- 第十三轮发现的 Redis 过期窗口集中清理和 usage 本地缓冲优雅停止仍待后续处理。尚未部署、未调整生产配置,未执行全工作区测试。 - -本轮构建、测试、压测及临时服务均已退出,原有开发服务未改动;格式及 diff 检查通过。验证过程中出现的缺失默认路由、旧指标入口、未签名 relay、夹具小栈溢出和流 ID 类型编译错误均已修正;此前失败或全量拒绝的报告不计入上述通过结果。 - -## 第十五轮修复状态 - -- **整窗过期时异步释放:** Redis 用量准入脚本原来逐条同步删除整个过期 sorted set,集中到期的大集合会长时间占用命令线程。现在对超过 256 条的集合检查首尾时间:首条未过期时跳过清理,末条也已过期时在同一次 Lua 调用内 `UNLINK` 整个键,让 Redis 在后台释放旧对象,随后仍按原规则准入并写入新事件。小集合及混合新旧记录的窗口继续精确删除,空集合直接使用计数 0。 -- **保留时间和事务语义:** 所有规则先清理,再检查,最后全部消费;任一规则拒绝时仍不写入新事件。后续规则的过期清理不会被前面规则的拒绝跳过。没有新增清理水位、辅助键或改变事件时间戳;乱序时间、重放、释放补偿、`Retry-After` 和键 TTL 沿用旧语义,无需迁移 Redis 数据。`UNLINK` 不可用或被 ACL 拒绝时退回原同步删除,不把未清理的旧记录当作已清理。 -- **减去请求内重复工作:** 清理后计数只在当前 Lua 调用内复用,不再重新 `ZCARD`;Rust 端用 `OnceLock` 复用不可变脚本对象,避免每个请求复制脚本文本和计算 SHA1。每次调用仍单独构造键和参数;Redis 脚本缓存丢失时仍由现有客户端处理 `NOSCRIPT` 并重新加载。 - -本地验证: - -- `cargo test --locked -j1 -p aether-runtime-state -p aether-usage-runtime`:状态模块 110 项、用量模块 349 项全部通过,共 459 项,0 失败;1 项较大数据计时基线默认忽略,已另行显式执行通过。日志:`/tmp/aether-round15-regression-tests.log`。 -- 新增 8 项真实 Redis 回归及 1 项计时基线。单独执行新增回归时确认 11 次隔离实例就绪,无 fixture 跳过,覆盖 Redis 8.0.3、RESP2/RESP3、ACL 认证及数据库 6。验证 10 万条记录一次 detach 后键复用、截止边界及 Lua 精确整数上界、混合窗口保留有效项、首规则拒绝时后续窗口仍清理、拒绝 UNLINK 后兼容、64 个并发争抢只放行 8 个、`SCRIPT FLUSH` 后幂等重载,以及 2 个协议各 512 步新旧脚本差分对照。对照逐步核对判定及完整剩余记录,包含时间回退、重复事件、额度变化和释放补偿。日志:`/tmp/aether-round15-redis-verified.log`。 -- 隔离 Redis 的 3 次对照各装入 30 万条同时过期记录。旧准入调用耗时 51.968 / 49.395 / 51.431 ms,新路径为 0.576 / 0.222 / 0.320 ms;另外装入 4096 条有效记录,各执行 1000 次持续准入,旧路径 P50/P95=86/123 us,新路径为 74/111 us,最终记录一致。日志:`/tmp/aether-round15-cleanup-timing-final.log`。测试验证逻辑和数据一致性,不以容易受本机负载影响的耗时阈值作为断言。 -- `cargo build --locked -j1 -p aether-gateway --bin aether-gateway` 完整构建与链接通过,耗时 4 分 15 秒,无警告;日志:`/tmp/aether-round15-gateway-build.log`。全工作区格式检查、新增 include 测试文件的独立格式检查及 diff 检查通过;本轮构建、测试和临时 Redis 实例均已退出,原有开发服务未改动。 - -本轮边界: - -- 这是整窗过期的优化,不是所有清理操作的硬耗时上限。混合窗口仍会同步删除其全部过期项;若大量旧记录与有效项同时存在,阻塞风险仍在。分批逻辑清理需要额外状态与完整的乱序、重试和滚动升级设计,不能直接以当前 cutoff 计数代替物理删除。 -- Redis 自动 TTL 过期的释放由服务端过期策略决定,可能先于本脚本发生,不由此改为异步。UNLINK 加速也依赖服务端支持及 ACL 许可;兼容回退仍有原同步释放成本。异步释放期间旧对象仍可能占内存,本轮没有提供 Redis 内存或 RSS 硬上限。 -- 数字来自本机 debug 构建、隔离 Redis 和单窗口局部对照,不代表网关端到端吞吐或生产 RPM 容量;未修改生产 Redis 配置、未部署,未重复上一轮业务压测。usage 停机排空及前轮观察到的高并发延迟仍待处理。 - -## 第十六轮修复状态 - -- **混合窗口的稀疏存活项重建:** 窗口超过 1024 条、有效项为 1–256 条且过期项超过有效项四倍时,先复制有效成员及原始 score 到临时键,再在同一次 Lua 内 `UNLINK` 旧集合并替换。先验证权限并完整构建临时集合,保留原 TTL;临时键已存在、Redis 缺少 ACL 检查接口或可选命令受限时继续精确同步清理。无清理水位或持久辅助数据,乱序、重放、多规则拒绝和释放语义不变。有效项超过 256 条的混合窗口仍走同步路径,不能据此宣称所有窗口清理已有硬耗时上限。 -- **请求与用量统一停机:** 收到退出信号后停止所有监听分片接收请求,HTTP/1 和 HTTP/2 等待已接收响应;默认 30 秒到期后取消剩余连接任务,并使升级后的连接读写返回关闭错误。对可能脱离 HTTP 生命周期的流式终态、取消回调及 WebSocket 审计,在创建后台任务前登记 producer。另将整条代理请求及响应体纳入登记,覆盖响应头前断开、内联响应体及断开后的后台消费,避免先关闭用量入口再提交终态的竞态。路由的 `cancel_on_client_disconnect=false` 策略保持继续完成上游请求,后台处理受后续用量排空期限约束;配置为 true 时按原规则取消并持久化取消终态。 -- **排空本地用量缓冲:** 等待 producer 结束后关闭生命周期入口,立即冲刷延迟事件并唤醒重试退避,等待各阶段与候选记录写入完成。已确认写入 Redis 的事件可留待下次消费;Memory 队列必须实际消费,存在未恢复的本地 DLQ 不判定为成功。worker 完成当前写入及 ACK 后停止,随后结束空闲分发任务和专用 Tokio runtime。超时明确报错;运行库层面保留未完成工作供再次调用停机,主进程不会把超时打印成排空成功。 -- **缩短上游空闲连接滞留:** reqwest 和浏览器传输的每客户端、每 origin 空闲连接默认上限由 1024 降至 32,默认空闲期限为 15 秒;H2C 池默认上限由 512 降至 32,并补上主动清理所需 timer。活动请求及响应流不受空闲期限限制。配置项为 `AETHER_GATEWAY_UPSTREAM_POOL_MAX_IDLE_PER_HOST` 和 `AETHER_GATEWAY_UPSTREAM_POOL_IDLE_TIMEOUT_MS`;HTTP、用量停止期限分别用 `AETHER_GATEWAY_HTTP_SHUTDOWN_TIMEOUT_MS`、`AETHER_GATEWAY_USAGE_SHUTDOWN_TIMEOUT_MS` 配置,均默认 30000 ms。 -- **保留恢复过程证据:** 压测探针记录有界的逐次排空观测,包含用量 producer、延迟事件、业务队列、Tokio 任务、连接和 FD。必要指标、任务基线容差以及连续 7 秒安静期保持原判定,新增本地待处理指标有值时也必须归零。 - -已完成的本地验证: - -- frontdoor 45 项、用量运行库 360 项、负载工具库 17 项、压力探针 21 项、状态模块 113 项、网关主程序 66 项、请求生命周期 8 项、传输模块 115 项及流式断开策略 1 项,共 746 项通过。状态模块另有 1 项默认忽略的计时基线,已显式执行通过。新增停机验证覆盖 producer 竞态、并发终态、重试失败后再次停止、延迟事件、多个 worker、写入途中停止、Memory 队列、响应头前断开、后台响应体、HTTP/1 和 HTTP/2 挂起处理器的实际释放,以及升级连接。日志:`/tmp/aether-round16-final-regressions.log`、`/tmp/aether-round16-state-regression.log`、`/tmp/aether-round16-main-final-tests.log`、`/tmp/aether-round16-request-lifecycle-tests.log`、`/tmp/aether-round16-transport-tests.log`。流式策略测试首次使用默认小栈时发生栈溢出;按网关入口相同的 8 MiB 栈设置 `RUST_MIN_STACK=8388608` 后通过,日志:`/tmp/aether-round16-disconnect-policy-test.log`。 -- Redis 新增混合重建测试覆盖 RESP2/RESP3、1/16/256/257 个有效项、TTL、非过期键、重复提交、已有临时键,以及 ZCOUNT/PTTL/EXISTS/UNLINK/RENAME/PEXPIRE 权限受限后的精确回退。定向 11 项测试确认 15 次隔离 Redis 实例就绪,无 fixture 跳过;原有 1024 步新旧脚本差分测试继续通过。日志:`/tmp/aether-round16-redis-tests.log`。 -- 同机 Redis 8.0.3 对照,30 万条过期记录分别混合 1/64/256 个有效项,旧脚本为 48.009/54.660/52.924 ms,新脚本为 0.320/0.377/0.496 ms,最终成员完全一致。整窗过期 3 次为旧脚本 56.802/51.804/50.886 ms、新脚本 0.744/0.258/0.318 ms。4096 条有效记录的持续调用 P50/P95 为旧脚本 87/131 us、新脚本 76/123 us;计时不作为断言阈值。日志:`/tmp/aether-round16-redis-timing.log`。 -- 网关主程序和压力探针完整构建、链接通过,无警告,日志:`/tmp/aether-round16-final-build.log`。补齐请求生命周期登记后再次完整构建主程序通过,日志:`/tmp/aether-round16-final-gateway-build.log`。 - -真实入口压力和停机验证: - -- 使用隔离 PostgreSQL 14、Redis 8.0.3 和约 2 秒的流式模拟上游,8 个 API key、4 个网关 worker、2 个监听分片、请求上限 384、TCP 上限 768、数据库池上限 24。要求完整读取且出现 SSE `[DONE]`。本轮 PostgreSQL 未关闭持久化,启动对照实查 `fsync`、`synchronous_commit`、`full_page_writes` 均为 on;Redis 夹具关闭 AOF/RDB,因此只验证保留 Redis 进程时的网关重启恢复。 -- 连接池单变量对照使用同一构建,仅设置旧空闲上限/期限 1024/90000 ms 或新默认 32/15000 ms。旧配置的 128、256 并发均全部 HTTP 200,业务待处理指标分别约 2.8/5.1 秒归零,但两档都未通过 150 秒恢复检查,最后 Tokio 活跃任务为 229/350、FD 为 206/327;脚本返回失败,不记为恢复通过。新配置分别在 22.217/26.542 秒通过原有恢复判定,FD 降至 65/63。这确认本地长时间恢复的主要滞留来自空闲连接及相关任务,而不是业务队列持续积压。对照目录分别为 `/tmp/aether-round16-pressure.3xsT7P`、`/tmp/aether-round16-pressure.iysPcj`;不把一次对照的吞吐或首字节波动视为可靠容量提升。 -- 最后补齐整个代理请求的 producer 登记后,使用最终构建再次执行新配置,结果如下。必要排空指标齐全,最终队列 lag、PEL、DLQ、outbox、本地用量待处理及数据库锁等待均归零,无 worker 处理失败;恢复判定包含连续 7 秒安静期。 - -| 并发 | 请求数 | 成功 / 失败 | RPS | 首字节 P95 | 完整响应 P95 | 恢复判定耗时 | 最终 FD / Tokio 任务 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| 128 | 1024 | 1024 / 0 | 56 | 150 ms | 2141 ms | 22.325 s | 65 / 87 | -| 256 | 2048 | 2048 / 0 | 115 | 155 ms | 2192 ms | 26.243 s | 63 / 85 | - -最终报告目录:`/tmp/aether-round16-pressure.JI8vPF`,执行日志:`/tmp/aether-round16-pressure-final-new.log`,验证脚本:`/tmp/aether-round16-pressure.sh`。该轮 256 并发 FD 峰值 595、最终 63;RSS 峰值约 283 MiB,检查结束仍约 282 MiB,没有把短时间 RSS 不下降判为泄漏或声称内存已回到启动值。 - -- 正常 SIGTERM:32 条已开始输出的流全部完整结束,网关成功退出;重启同一数据库与 Redis 后,核对 3104 条 completed,逐条 input/output/total tokens 为 1/20/21,首字节时间无缺失。 -- 将 HTTP 停止期限缩短为 50 ms:默认继续完成策略下,32 个客户端都观察到流中断,网关继续完成其后台请求后成功退出;恢复后 3136 条全部 completed,token 合计 3136/62720/65856,逐条仍为 1/20/21。随后仅在测试数据库启用断开取消策略,另外 32 条流中断后全部落为 cancelled、HTTP 499、billing_status=void、token 为 0,首字节时间保留。最终数据库为 3136 条 completed 加 32 条 cancelled,未把默认继续完成误判为取消。 -- 最终脚本对 SQL 完整性失败显式退出。早期验证脚本误将默认断开策略预期为取消,并遇到旧 Bash 的条件失败未中止问题;已修正断言和预期,上述停机终态与 token 结论只采用最终目录的复测结果。 - -全工作区格式检查、新增 include 测试文件的独立格式检查及 diff 检查通过。本轮构建、测试、压测、临时 PostgreSQL/Redis 和模拟服务均已退出,原有开发服务未改动。 - -边界: - -- 新连接池设置只限制空闲缓存,不能推导整个进程的 FD 或 RSS 上限,也不能替代活动请求并发预算。减少空闲连接会增加突发流量之间重新建立连接的次数;需要在目标环境观察握手成本及连接复用。 -- 有界停机无法保证 SIGKILL、依赖持续故障或超过停止期限时的本地未持久数据不丢失;本轮未引入磁盘日志或更换消息架构。进程管理器的强杀期限应覆盖两阶段停止和额外收尾时间。 -- 第十四轮短响应夹具在 256 并发的延迟超预算尚不能判定已解决;本地 debug、模拟上游和短时压力结果不代表生产容量。未部署或修改生产配置,未执行全工作区测试。 - -## 第十七轮修复状态 - -本轮继续处理混合过期 Redis 大窗口和短响应网关的历史记录读取开销,未部署生产。 - -- **Redis 混合大窗口:** 当过期项超过 4096、有效项超过 256 时,先执行只读规划,随后在独占连接上 WATCH 全部规则,每条命令最多复制 512 个有效成员;通过 MULTI/EXEC 校验期间没有源数据变化,最后在一个 Lua 调用中替换窗口并完成原有多规则检查与消费。旧进程的 ZADD/ZREM、TTL 变化也会使 WATCH 失效。复制期间不提前清理其他规则,避免乱序请求、重复事件和跨规则拒绝改变原有额度语义。 -- **维护资源限制:** 每个 Redis runtime router 最多两条按需维护连接,最多尝试八次,包含连接池等待的总耗时受原有命令超时约束;未配置超时时使用 30 秒上限。取消或失败会丢弃仍有 WATCH/MULTI 状态的独占连接,未提交副本有 60 秒 TTL。成功提交后没有额外的可失败网络收尾操作。缺少所需 ACL 能力或 Redis 不支持 ACL 预检时保留原有精确清理路径。 -- **候选内存仓库索引:** 请求更新不再全表查找逻辑主键,按 request_id 查候选;最近记录查询改用创建时间索引,在复制之前取 limit。三个索引在同一写锁中维护,覆盖 ID 替换、相同时间排序和删除空索引。所有写入入口保留清洗,读取不再重复重建已经清洗的 JSON。 -- **公共候选诊断清洗:** 提取借用 JSON 对象的内部函数,移除持久化清洗前的整份输入克隆、清洗结果克隆及诊断对象的再次克隆;字段白名单、诊断大小限制、管理员与公开投影语义不变。PostgreSQL 与 Memory 都调用这一公共函数。 -- **调度读取精简投影:** 新增 `list_recent_runtime` 读取身份、状态、计数及时间,Memory 从时间索引直接构建不带诊断的记录,PostgreSQL 查询不读取诊断和能力 JSON。调度与自适应 RPM 观察使用此路径,管理员和请求可观测性继续读取完整记录。精简前后并发计数、RPM 和失败冷却判断保持一致。 - -已完成 241 项检查:状态模块 119 项、候选仓库与数据契约 28 项、调度核心 92 项、隔离 PostgreSQL 14 精简投影检查 1 项,以及单独执行的 Redis 大窗口计时检查 1 项。PostgreSQL 检查使用独立临时实例和连接级临时表,包含 32 KiB 诊断字段,验证精简投影与完整记录的运行时字段一致,且完整读取仍保留诊断。最终网关压测程序编译通过;临时数据库均已停止。日志:`/tmp/aether-round17-state-complete-tests.log`、`/tmp/aether-round17-projection-tests.log`、`/tmp/aether-round17-scheduler-tests.log`、`/tmp/aether-round17-postgres-live.log`。 - -首次短响应采样确认调度等待 `list_recent` 的全局读锁,读路径原本复制全部历史记录再逐条重做 JSON 清洗;仅修索引后最近 128 条完整 JSON 的复制与清洗仍是热点,因此继续将调度读取改为精简投影。最终采样仍有候选仓库读写锁等待,但没有原先全量历史复制的放大行为,其他开销分布在请求处理、凭据解密和 JSON 构建。本机其他进程负载较高,不能直接与第十四轮数字比较。 - -短响应对照使用本轮修改前保留的二进制与最终二进制,同为本机 debug 构建、Memory 网关夹具、模拟上游约 75 ms、每点请求量为并发数的八倍。最终版本及随后复跑的对照版各执行 15344 个请求,全部成功,无失败或拒绝。网关 P95 单位 ms: - -| 场景 | 并发 | 修改前复测 P95 | 修改后 P95 | 修改后 RPS | -| --- | --- | --- | --- | --- | -| 同步 | 128 | 1025 | 222 | 857 | -| 同步 | 256 | 2049 | 408 | 800 | -| 流式 | 128 | 569 | 263 | 678 | -| 流式 | 256 | 1564 | 451 | 794 | - -128 并发达到该夹具的 300 ms 预算;256 并发两种网关路径仍超过预算。最终版本中独立执行器和隧道曲线的 P95 均低于预算。结果位于 `/tmp/aether-round17-capacity-final.json`、`/tmp/aether-round17-capacity-control-recheck.json`。本机对照时系统 CPU 约 92%–99%,同一进程包含压测客户端、网关与模拟上游,不能将这里的 RPS 视为生产容量。用于 CPU 采样的额外长测有采样器干扰,未用于上述性能对照。 - -Redis 8.0.3 同机前后对照,均含 300000 条过期记录,单位 ms: - -| 有效项 | 旧整次调用 | 新整次调用 | 旧最长命令 | 新最长命令 | -| --- | --- | --- | --- | --- | -| 257 | 64.750 | 2.199 | 64.351 | 0.218 | -| 4096 | 77.099 | 7.331 | 76.301 | 0.681 | -| 32768 | 62.999 | 45.464 | 62.583 | 0.914 | -| 150000 | 77.200 | 242.083 | 76.840 | 1.585 | - -最终成员与旧脚本一致;最长命令来自隔离 Redis 的 SLOWLOG,包含 EVAL/EXEC。有效项多时整次清理更慢,但复制批次之间允许其他命令执行,降低对同一 Redis 其他请求的连续阻塞。原有整窗过期和不超过 256 个有效项的快速路径继续通过。4096 条有效项持续调用 1000 次,旧 P50/P95 为 155/286 us,新为 152/310 us;未将计时作为测试断言。记录:`/tmp/aether-round17-redis-timing.log`。 - -边界:复制需要临时保存有效成员,额外内存与有效项数量相关;频繁并发修改可能触发重试或达到命令期限,未提交的复制不会替换原始窗口。提交成功但回复丢失仍具有现有 Redis 命令的结果不确定性,应使用同一事件 ID 重试。ACL 不足或不支持预检的 Redis 仍走同步回退,不应把本优化描述为所有配置下的 Redis 总阻塞硬上限。代码层面的已复现放大路径已修复,但不能据此宣称所有容量问题均已解决;256 并发的最终验收仍需在代表性环境运行 release 构建并结合持续压测确认。未部署生产。 - -## 主要判断 - -系统已经有入口并发门、请求体内存预算、认证缓存合并、前后台数据库池隔离及多种有界队列。问题主要在于部分请求路径仍执行全量统计、锁范围过大,以及资源预算和超时没有覆盖完整生命周期。 - -这些问题会相互放大:每请求成本增加,使请求停留更久;在途请求增加后继续竞争 Redis、数据库和内存,客户端重试又增加负载。只提高入口并发数或数据库连接数不能消除这些瓶颈。 - -平均在途请求约为 `RPM / 60 × 平均请求持续秒数`。例如 600 RPM、平均持续 60 秒,约有 600 个在途请求;该例是容量计算,不是当前实例测量值。 - -## P1-1:压缩或未知长度请求一次占满全局请求体预算 - -**代码:** - -- `crates/aether-gateway/frontdoor/src/body.rs:392`:带非 identity Content-Encoding,或缺少 Content-Length 时,预留 `min(单请求上限, 全局预算)`。 -- `apps/aether-gateway/src/state/app.rs:40`:默认全局预算 256 MiB;`apps/aether-gateway/src/headers.rs:20` 的单请求默认上限同为 256 MiB。 -- `apps/aether-gateway/src/state/app.rs:195`、`crates/aether-gateway/frontdoor/src/body.rs:201`:默认没有请求体完整读取超时。 -- `crates/aether-gateway/frontdoor/src/body.rs:154`:等不到预算则返回过载;默认等待预算 250 ms。 - -**结果:** 即使压缩后的请求只有 1 KiB,也会预留全部 256 MiB。压缩上传或未知长度上传的读取阶段因此被串行化;一条一直未传完的请求可以长期占住预算,其他需要读取请求体的请求陆续收到 503。这里的 256 MiB 是预留额度,并非立即分配的实际内存。 - -**验证:** 使用实际 `BodyBufferPolicy` 的独立程序复现:1 KiB gzip 请求预留 268435456 字节,剩余许可为 0;第二个普通 1 KiB 请求等待约 252 ms 后被拒绝,释放第一个预留后立即恢复。程序显式采用默认参数,验证的是预算准入,不是完整 HTTP 慢上传压测。源码位于 `/tmp/aether-frontdoor-budget-repro.rs`,可执行程序位于 `/tmp/aether-frontdoor-budget-repro`。`cargo test -p aether-gateway-frontdoor body::tests -- --nocapture` 的 13 项既有请求体测试通过。 - -**建议:** 设置适合实际上传大小和速率的读取期限;根据业务明确单请求大小上限,使一个普通上传不能耗尽全局预算。进一步改为分段、有界读取及解压预算,或为大型上传分配独立额度。增量预留必须避免多个请求各持部分预算、同时等待扩容导致死锁,不能简单取消当前保护。 - -## P1-2:调度请求执行 Redis 全库扫描和历史样本聚合 - -**代码:** - -- `apps/aether-gateway/src/dispatch/pool_scheduler.rs:142`、`:985`:候选分页和 sticky 路径调用管理侧 runtime 统计函数。 -- `apps/aether-gateway/src/handlers/admin/provider/pool/runtime/reads.rs:126`:cache affinity 且 sticky TTL 非零时,扫描所有匹配会话,再 MGET 全部结果。 -- `crates/aether-runtime/state/src/redis/runtime.rs:289`:SCAN 循环直到游标归零,COUNT 200 是每轮提示,不是总量上限。 -- `apps/aether-gateway/src/dispatch/pool_scheduler.rs:1984`:扫描生成的会话总数和按 key 统计并不进入实际调度状态。 -- `apps/aether-gateway/src/handlers/admin/provider/pool/runtime/reads.rs:208`、`:225`:无条件对每个候选 key 拉取成本和延迟窗口的原始成员,即使相应排序或限额未启用。 - -**结果:** 一页 64 个不同候选 key 就产生 128 次窗口查询,另加会话扫描等操作。成本窗口默认 5 小时,历史样本按时间修剪;随着 RPM 增加,每个请求需要读取和聚合的历史数据也增加。`join_all` 并发等待不会消除 Redis 执行量及返回数据量。SCAN 和窗口读取都使用 Admin lane,管理请求也可能受到影响。 - -**建议:** 为调度建立独立读取接口,只读取当前 sticky 绑定及调度需要的状态;会话总数留给管理统计。按启用策略读取成本或延迟,改用增量计数、时间桶或后台维护的短时快照;保留严格额度检查的原子性。对相同池的刷新合并,避免每个请求重复拉取历史。 - -## P1-3:结算错误地锁住公共套餐行,跨用户串行 - -**代码:** `crates/aether-data/adapters/postgres/src/settlement.rs:482` 的 `user_plan_entitlements JOIN billing_plans ... FOR UPDATE` 没有限定锁的表。 - -**触发:** 普通用户的非零费用结算,且用户有有效套餐。即使套餐没有 daily_quota,查询也先锁行,之后才判断 `grants.is_empty`。 - -**结果:** 不同用户只要使用同一个套餐,就会争抢同一条 `billing_plans` 行。锁持续到事务结束,其间还可能汇总每日账本、写额度流水、更新钱包和结算快照。首先影响后台结算和队列消化速度,不能据此断言前台数据库池必然同时耗尽。 - -**验证:** 在隔离 PostgreSQL 14.17 中,两用户拥有不同 entitlement、共享一个 plan。事务 A 持原 SQL 锁时,事务 B 因 500 ms lock_timeout 失败,错误明确指向 `relation "billing_plans"`;对照使用 `FOR UPDATE OF user_plan_entitlements` 后事务 B 成功。临时 PostgreSQL 已停止。该验证证明锁冲突,不是完整结算吞吐压测;部署 Compose 使用 PostgreSQL 15。 - -复现脚本:`/tmp/aether-plan-lock-repro-20260909.sh`;本次日志:`/tmp/aether-plan-lock-repro.XoYxfO/`。 - -**建议:** 将锁范围限定为需要更新的用户 entitlement;明确套餐配置并发变更的一致性规则。添加真实双连接回归:同用户额度不能重复扣,不同用户同套餐不能互相阻塞。 - -## P1-4:首包之后的流缺少空闲期限 - -**代码:** - -- `apps/aether-gateway/src/execution_runtime/stream/execution.rs:2699`:默认 inline 直通路径仅在首包前使用 timeout,首包后直接 `upstream.next().await`。 -- `apps/aether-gateway/src/execution_runtime/transport.rs:3375`:stream 不使用总请求 timeout。 -- `apps/aether-gateway/src/execution_runtime/transport.rs:4212`:该 reqwest 客户端配置连接超时,没有设置读取超时。 - -**结果:** 上游已经发送首包、随后不再发送数据也不关闭连接时,只要下游继续保持连接,该流就可能长期占据请求名额、provider 并发守卫及缓冲。坏流逐渐积累会压缩可用容量。target permit 在首次向客户端 yield 时释放,不能把它算作整条流一直占用的资源。 - -**建议:** 增加可按 provider 配置的上游空闲期限;计时以真实上游活动为依据,网关自己的 keepalive 不应重置它。超时后关闭上游并走一次终态结算,确保断开、取消和超时都释放请求及 provider 守卫。对合法长思考模型采用匹配其行为的阈值。 - -## P1-5:并发上限与实际常驻内存不匹配 - -**代码:** - -- `apps/aether-gateway/src/main.rs:433`、`:467`:自动入口上限为每 CPU 1024,结合 FD 下调,但没有内存预算。 -- `apps/aether-gateway/src/execution_runtime/stream/execution.rs:167`、`:400`:Basic 模式每份流分析缓冲上限 5 MiB;Full 为 64 MiB。 -- 同文件 `:1993`、`:2031`:分别累积 provider 和 client 两份 body,包括直通路径。 - -**结果:** 两份捕获缓冲达到上限时,Basic 单流约 10 MiB,Full 约 128 MiB,尚未计入请求 JSON、转换状态、队列及其他内存。1000 条都达到 Basic 捕获上限的流,仅这两份内容就约 9.77 GiB。这是达到上限时的预算估算,不是普通小回复的固定内存或已测 RSS。 - -**建议:** 用实测每请求内存和 cgroup/物理内存确定入口容量;为响应捕获建立全局字节预算和截断策略。Basic 优先使用增量 usage/error 解析,直通时避免重复捕获同一内容。请求体读取预算不能充当整个流生命周期的内存保护。 - -## P1-6:审计压缩在 Tokio 线程和数据库事务内同步执行 - -**代码:** `crates/aether-data/adapters/postgres/src/usage/mod.rs:8457` 开始事务并锁 request;`:8522` 同步准备审计内容;`:12362` 序列化 JSON,`:12376` 执行 gzip level 6。`:12478` 附近最多处理四份请求/响应 body;inline 阈值为 0。 - -**结果:** 有 body 捕获,尤其大上下文、高并发时,CPU 压缩同时占据 Tokio 工作线程、数据库连接和事务锁。前后台连接池隔离不能隔离同一进程内的 CPU 和内存争用。 - -**建议:** 在开启事务前完成可独立准备的序列化和压缩,放到有并发和字节预算的 blocking worker;事务内只保留必须原子执行的读写。检查相同 body 的去重,避免单纯增加 worker 数量导致 CPU 和内存进一步饱和。 - -## P1-7:长期额度准入按用户串行扫描,且默认无锁等待期限 - -**代码:** `crates/aether-data/adapters/postgres/src/settlement.rs:276` 锁 `users` 行;`:611`、`:850` 在准入中取得该锁后,分别逐窗口 COUNT 请求预留或 SUM 成本预留。释放及对账还会争同一用户锁。`crates/aether-data/adapters/postgres/src/tx.rs:15`、`:116` 的默认读写事务不设置 lock_timeout 或 statement_timeout。 - -**触发:** 配置长期请求额度或成本上限;不能把普通短期 RPM 规则一概算入这条路径。 - -**结果:** 同一用户多个 key 的准入串行;窗口内历史越多,锁内工作越多。等待锁的事务还占用连接。池 acquire_timeout 只管取得连接之前的等待;Compose 的 idle_in_transaction_session_timeout 也不能终止正在执行的锁等待 SQL。 - -**建议:** 按用户和额度窗口维护原子聚合及预留,避免准入反复扫描明细。为前台和后台事务分别设定锁等待和 SQL 期限,失败时回滚并限制重试;精确额度和幂等结算约束必须保留。 - -## 次要放大器 - -- **P2,过载后重复计算:** `apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs:100` 在 API key 并发受限时短间隔重做候选读取和排序。建议在昂贵规划前做准入,使用有界等待或通知。 -- **P2,探测前置任务未合并:** `apps/aether-gateway/src/maintenance/runtime/pool_quota_probe.rs:1675` 先 spawn,再去 Redis 去重及争锁。池恶化时仍随请求量创建任务。建议在 spawn 前按 provider 合并触发信号。 -- **P2,同步日志输出:** `crates/aether-runtime/base/src/tracing.rs:403` 直接写 stdout,`:695` 的文件写持全局 Mutex。磁盘或日志收集变慢时可能阻塞 Tokio worker。改为有界日志队列,并明确队列满时策略;当前没有证据证明这是本次故障主因。 - -## 修改和验收顺序 - -1. 先修公共套餐锁范围、读取期限及压缩上传预算问题;这些都有明确且局部的触发条件。 -2. 从请求调度移走管理扫描,按需读取运行态,避免每请求重算历史统计。 -3. 修流空闲期限,建立响应捕获全局预算,将压缩移出事务及异步工作线程。 -4. 优化长期额度聚合,并为数据库锁等待、SQL 执行和过载重试设置预算。 -5. 用独立测试实例及可控制延迟的 mock upstream 做阶梯压测;每档记录吞吐、P95/P99 首包及总耗时、RSS、CPU、队列积压,停止流量后检查资源能否回落。 - -生产定位至少需要:故障实例和版本、CPU/内存/FD 配额、实际 RPM 和平均流时长、是否使用压缩上传/账号池/套餐/完整 body 捕获。采集 `pool_runtime_state` 阶段延迟、Redis Admin lane 延迟、数据库 checked-out/lock waiting、usage 队列 lag 和 Tokio 任务数。CPU 低且锁等待高、Redis 延迟高、RSS 持续涨、请求体大量 503 分别对应不同路径,不能仅凭“卡死”选择一个原因。 - -现有 `crates/aether-testing/loadtools/src/bin/gateway_pressure_probe.rs` 可用于受控环境的压力和排空观测;不要用健康检查接口的吞吐代替真实 AI 调度及结算链路的容量。 diff --git a/docs/operations/conversion-failure-diagnostics.md b/docs/operations/conversion-failure-diagnostics.md deleted file mode 100644 index f1fec3b1d..000000000 --- a/docs/operations/conversion-failure-diagnostics.md +++ /dev/null @@ -1,42 +0,0 @@ -# 格式转换失败诊断导出 - -## 使用方法 - -在请求详情的失败或跳过节点中,点击「失败诊断」面板的复制按钮。 -复制时才会读取已采集的正文;页面预览本身不会批量加载正文。 -请分享整个 JSON,而不是只分享 `summary`。复制成功标志仅在剪贴板写入成功后出现。 - -## Schema v2 - -- `diagnostic`:错误码、请求/响应/流式阶段、源/目标格式、转换器标识、完整 JSON 路径及期望约束/实际值。 -- `path_source`:`structured` 是后端结构化路径,`message_inference` 是历史文案推断,`protocol_inference` 是根据上游协议推断的原始字段路径,`unavailable` 表示没有可靠字段路径。通用 `$.finish_reason` 会按协议定位到具体原始字段,同时保留 `reported_path`。 -- `stage_source`:区分后端阶段信息与历史记录推断。请求转换方向为客户端到上游;响应/流式转换方向相反。 -- `versions`:前端版本、导出时网关版本、失败时运行版本。历史记录没有运行版本时保留 `null`,不能把导出版本当作失败版本。 -- `request` / `node`:请求、候选、重试、模型及时间等定位信息。 -- `reproduction.sources`:脱敏正文片段、字段样本和流式失败事件窗口。数组通配路径的样本带具体下标。 -- `reproduction.missing_context`:未采集、无权限、正文过大、读取失败、缺少失败事件或路径等缺口。 - -后端只为明确匹配 `candidate_id` 的候选提供上游正文记录。历史记录仅有候选索引时,不把最后一次重试的正文猜成当前失败的正文。 -原始客户端请求可在同一请求内共享,但不会把其他候选的上游请求/响应当作失败现场。 -`body_ref` 不是下载地址;前端不访问其中的 URL,而是使用现有、受权限保护的正文接口。 - -## 完整性与安全边界 - -- `not_loaded`:尚未补取上下文。 -- `sanitized_context`:必要来源已取得,但仍然经过脱敏、大小限制或事件窗口裁剪。 -- `insufficient_context`:还缺少明确列出的信息,不能据此假设能够完整复现。 -- `replay_ready: false`:导出的是供排查的证据包,不是可以无条件自动执行的请求。修改前应根据样本建立最小回归测试。 - -每份正文的下载/解码处理上限为 1 MiB,读取超时为 5 秒;导出 JSON 上限为 64 Ki 字符。 -字符串、数组、对象深度与节点数量也有限制。流式窗口保留匹配失败的事件、帧序号及邻近事件;匹配不到时明确标记,而不是认定流尾就是故障点。 - -默认移除常见认证头、密钥、令牌、Cookie、密码、签名 URL 参数、正文文本和二进制数据。 -脱敏是规则化处理,分享前仍需检查自定义字段和错误消息是否含业务敏感信息。 -不会为了诊断绕过正文采集策略、授权或存储限制;也不会把 `error` 或未知结束原因映射成正常成功。 - -## 建议处理流程 - -1. 检查 `diagnostic.stage`、`path_source` 和 `missing_context`,区分转换器缺陷、合法的无损转换拒绝和上游失败。 -2. 对照源/目标格式以及字段样本,建立最小失败输入;流式问题同时保留必要的前序事件。 -3. 先补失败回归测试,再修复转换规则。 -4. 验证原有正常映射、失败闭合以及凭据脱敏没有回退。 diff --git a/docs/operations/dns-egress-audit-2026-09-08.md b/docs/operations/dns-egress-audit-2026-09-08.md deleted file mode 100644 index 13958c048..000000000 --- a/docs/operations/dns-egress-audit-2026-09-08.md +++ /dev/null @@ -1,171 +0,0 @@ -# DNS 与出站连接审计(2026-09-08) - -## 范围与结论边界 - -本轮基于当前工作区检查 `apps/`、`crates/` 中的 DNS 查询、IP 地址校验、 -客户端构建、代理配置以及 TCP/WebSocket 连接入口,沿调用关系区分供应商请求、 -身份认证、任意 URL 下载和隧道转发。不是仅搜索 `WebSocket` 或 `chatgpt.com`。 - -第一轮 WS 修复不足以说明所有路径已经一致。本轮又发现连接测试的旧地址过滤、 -URL 形式 IPv6 误入 DNS、两处 DNS 答案静默截断,以及附件下载只保留首个地址。 -这些问题,以及后续复核发现的 SMTP 无界解析和隧道缺少显式远程 DNS 模式, -已在工作区修复。本文不表示生产服务器已经部署,也不保证真实上游的 -DNS、TLS、TUN 路由或出口代理一定可用。 - -## 已修复问题 - -### 1. 普通供应商出口的策略重复 - -- 普通 HTTP/SSE、浏览器指纹 HTTP、H2C 已使用供应商解析策略;普通 WS 仍有独立过滤, - 已在第一轮改为使用 `ExecutionSafeDnsResolver`。 -- `/v1/test-connection` 的本地快捷路径仍逐项拒绝私网/保留 DNS 答案,导致同一个 - 配置好的供应商正式请求能成功、连接测试却失败。本轮删除该重复策略,复用正式 - HTTP 客户端的解析器。 -- HTTP、WS 和连接测试统一使用 `validate_execution_upstream_url` 校验供应商 URL。 - 域名的 DNS 答案不按地址段过滤,不等于允许在 URL 中直接填写任意私网 IP。 -- URL 中的凭据、fragment、私网/保留 IP 字面地址仍被拒绝;HTTP/WS 字面 loopback - 保持正式执行路径已有的兼容策略。禁用重定向、供应商显式代理和 WS 自循环检查保留。 - -相关文件: - -- `apps/aether-gateway/src/execution_runtime/transport.rs` -- `apps/aether-gateway/src/handlers/proxy/websocket/transport.rs` -- `apps/aether-gateway/src/handlers/public/support/test_connection/route.rs` - -### 2. IPv6 字面地址被当作域名 - -`Url::host_str()` 可提供 `[::1]` 形式的主机名,而 `IpAddr::from_str` 和 -`lookup_host((host, port))` 的原有调用没有正确消化这个形式。修复前新增测试实际失败, -错误为 `failed to lookup address information: nodename nor servname provided, or not known`。 - -- 公共解析器现在先识别 IPv4、裸 IPv6 和合法的方括号 IPv6,直接生成 socket 地址。 -- 不接受 `[localhost]`、`[127.0.0.1]` 等伪造的方括号主机名。 -- relay 的 loopback 判断也使用同一解析函数,防止解析成功后又误判 `[::1]`。 -- 隧道 SOCKS 地址编码复用该函数;IPv6 字面地址不再在远程 DNS 模式下被当作域名发送。 -- 私网过滤仍由每个调用方的安全策略决定,公共解析器本身不扩大地址权限。 - -相关文件:`crates/aether-http/src/dns.rs`、 -`apps/aether-tunnel/src/egress_proxy.rs`、网关执行传输模块。 - -### 3. DNS 答案静默截断 - -Bark 推送和 ChatGPT-Web 图片解析仍直接调用系统 DNS,然后仅取前 32 个答案。 -本轮改用共享的 `lookup_host_with_limits`:保留原超时预算,超过 32 个答案直接报错, -不再静默忽略剩余答案。两条路径的私网校验、地址固定及官方来源 Fake-IP 例外不变。 - -owner gateway 转发的独立实现已取第 33 个答案并拒绝超限,因此不是同类遗漏。 - -### 4. Grok 附件下载缺少多地址回退 - -原实现校验全部 DNS 答案后只固定第一个公网地址;首个地址不可连接时,客户端无法 -尝试 DNS 返回的其它公网地址。本轮改为把全部经过校验的地址交给客户端,保留双栈和 -多地址回退能力。空答案、Fake-IP、私网及公网/私网混合答案仍整体拒绝。 - -### 5. SMTP DNS 不受连接超时控制 - -SMTP 发送和连接探测现在都先用共享异步解析器建立 TCP 连接,再把已连接的 blocking -socket 交给原来的 SMTP/TLS 协议实现,不在阻塞任务中重新解析或连接。 - -- DNS 最长 10 秒;DNS 与 TCP 尝试共用 30 秒总预算。 -- 保留全部不超过 32 个答案,不再静默截断为前 16 个;超限直接报错。 -- TCP 地址竞争取首个成功连接并释放其它尝试,避免首个黑洞地址吃完整个预算, - 使后续可达地址根本没有机会建连。SMTP/TLS 仅在最终选中的连接上运行。 -- 解析失败、空答案、解析超时及总连接超时有明确错误,不把底层 DNS 细节暴露给调用方。 -- TLS 继续验证原始 SMTP 主机名;读写超时仍为 30 秒,内网邮件服务器策略不变。 - -相关文件:`apps/aether-gateway/src/email_delivery.rs`。 - -### 6. 隧道供应商出口可显式委托代理解析 - -新增默认关闭的 `upstream_proxy_remote_dns`(CLI `--upstream-proxy-remote-dns`,环境变量 -`AETHER_TUNNEL_UPSTREAM_PROXY_REMOTE_DNS`,setup 的 `Proxy Remote DNS` 开关)。 - -- 默认路径仍本地解析、执行 ACL、固定 IP,`socks5h://` 本身不改变既有安全策略。 -- 显式启用后,HTTP CONNECT 或 SOCKS5h 接收原始域名,不再预先查询隧道本机 DNS; - Host、SNI 和证书校验仍保留原域名。 -- 必须配置 HTTP/SOCKS5h 代理;无代理或 `socks5://` 会在启动和客户端构建时拒绝。 -- 仍执行端口白名单、URL 校验以及 IP 字面地址/`localhost` 限制;字面 IP 保持固定。 -- 远程 DNS 和固定 IP 使用不同连接池键;远程模式解析器明确拒绝本地 DNS 回退。 -- 代理 DNS、TCP、CONNECT/SOCKS 和 TLS 握手共同受上游连接超时限制。 -- 启用时打印安全提示:**域名目标的最终 IP ACL 由受信任代理负责**。普通 CONNECT/ - SOCKS5 协议无法让隧道校验代理最终连接的 IP,不能宣称远程解析仍保留本地逐 IP 检查。 - -配置示例及部署边界见 `apps/aether-tunnel/README.md` 的“上游 HTTP 请求”章节。 -该文档中 DNS 缓存和连接超时等环境变量误写的 `_SECS` 后缀也已纠正,避免按文档 -设置后实际未被程序读取;CLI/TOML 参数名称不变。 - -## 必须保留的策略差异 - -| 路径 | DNS / 代理策略 | 本轮处理 | -| --- | --- | --- | -| 普通供应商 HTTP/SSE、浏览器指纹、H2C、WS、连接测试 | 域名答案不按地址段过滤;显式代理优先;供应商客户端不自动使用系统代理环境变量 | 统一遗留分支 | -| 供应商操作类 OAuth、模型获取 | 经执行计划进入供应商运行时;不能与用户登录的身份 OAuth 混为一谈 | 核对调用关系 | -| 身份 OAuth / 管理端 OAuth 探测 | 独立敏感出口;校验目标、固定地址;部分内置官方来源允许窄范围 Fake-IP | 保留,不全局放开 | -| Grok 用户附件、公共视频 URL | 不可信 URL;保留公网限制和固定地址,不能套用供应商域名策略 | Grok 保留全部安全地址 | -| ChatGPT-Web 图片下载与上传 | 普通 URL 严格过滤;可信存储来源有专门 Fake-IP 例外 | 公共有界解析器 | -| 支付出口 | 独立公网校验;固定 Stripe 来源有专门 Fake-IP 例外 | 保留 | -| 系统更新、外部模型目录、Server Chan、Bark | 各自的可信来源例外;自定义目的地不能自动获得同样权限 | Bark 公共有界解析器 | -| gateway owner / internal relay | 独立私网策略、可信 relay 配置和地址固定 | 保留;修复 IPv6 判断 | -| 隧道承载的供应商 HTTP 流量 | 默认本地端口/IP ACL 和固定 IP;显式远程模式委托受信任代理解析及执行域名 IP ACL | 新增默认关闭的远程 DNS 模式 | -| 隧道到 gateway 的控制连接 | 与隧道供应商出口分开;可配置专门出口代理和 IP family | IPv6 / SOCKS 编码复用公共函数 | -| 独立 Responses WS probe | 独立直连诊断程序,不使用供应商代理配置 | 不应当作生产代理路径的等价验证 | - -## 仍需注意的实际限制 - -1. **Fake-IP 只是地址,不提供路由。** 取消供应商 DNS 地址过滤后,进程所在网络仍必须 - 能通过对应的 TUN/透明代理处理 Fake-IP;否则会变成 TCP 超时,而不是过滤报错。 -2. **隧道代理不等于网关直连代理。** 默认仍先本地解析并通过 ACL;只有显式启用 - `upstream_proxy_remote_dns` 才委托代理解析供应商域名。代理端点自身的域名仍需 - 本地 DNS;本地解析完全不可用时,代理 URL 应使用可达 IP。 -3. 隧道默认 `AETHER_TUNNEL_ALLOW_PRIVATE_TARGETS=false` 仍会拒绝 Fake-IP。 - 该开关是扩大内网访问权限,不是建议普遍启用的 DNS 修复;应优先让隧道主机得到 - 可路由的真实 DNS 答案,或配置受信任代理的显式远程 DNS 模式。 -4. 更新客户端支持自己的代理环境变量和 `NO_PROXY`;不能把这一点推广到供应商请求。 - 网关供应商 SOCKS 配置会归一化为远程 DNS 语义,但其它明确区分 SOCKS5/SOCKS5h - 的独立工具仍遵循各自配置。 -5. SMTP 等辅助服务不是供应商解析器的调用方。SMTP 已修复 DNS 超时和答案截断, - 但不自动继承供应商出口代理。系统解析器由 Tokio 阻塞池承载,异步超时会停止等待, - 不等于操作系统正在执行的 DNS 调用能够被强制终止。 -6. 本轮没有使用生产凭据、发送真实模型请求、修改系统 DNS、关闭 TUN 或重启服务器。 - 生产验证仍需在实际容器/进程的网络命名空间内进行。 - -## 回归验证 - -- 公共 DNS:合法/非法 IPv6 主机形式、端口、零超时、答案上限、超限拒绝。 -- 供应商 DNS:Fake-IP 保留,HTTP 与 wreq 解析结果一致;relay 私网过滤不变。 -- WS:普通与浏览器指纹客户端,经本机 HTTP、SOCKS5、SOCKS5h mock 代理,使用 - `provider-dns.invalid` 完成真实 WS upgrade;SOCKS mock 断言收到域名而非本地解析 IP。 - 这是本地明文 WS 的代理路径测试,不代替生产 WSS 的 TLS/SNI 检查。 -- 连接测试:供应商 URL 校验一致,无预解析构建请求,重定向不转发凭据。 -- 附件与辅助出口:多地址保留、私网/混合答案拒绝及官方 Fake-IP 例外回归。 -- 隧道:IPv6 SOCKS 编码、地址 ACL、缓存策略隔离、默认固定 IP 代理连接;新增远程 - DNS 模式的 HTTP CONNECT/SOCKS5h 域名握手、Host/SNI 主机名、禁止本地解析回退、 - 连接池隔离、代理/TLS 握手超时,以及 CLI/TOML/TUI 配置验证和持久化。 -- SMTP:DNS 超时、空答案/错误信息、前 16 个地址不可用时使用第 17 个地址,以及 - 本机 mock SMTP 探测和邮件投递;不发送真实邮件。多地址测试先复现串行建连超时, - 改成有界地址竞争后通过。 - -最终重新执行结果:**809 项测试通过,0 失败**。 - -- 网关:594 项,覆盖完整 WS 模块、执行传输、Grok、ChatGPT-Web 图片、连接测试, - DNS/Fake-IP/解析地址校验,以及 SMTP 发送、探测和相关配置回归。 -- 隧道:197 项,全量单元/本机集成测试。 -- `aether-http`:18 项,包含先失败、后修复通过的方括号 IPv6 回归。 -- 单独执行的 13 项 SMTP 回归及前轮测试均为上述集合的子集,不重复计入总数。 -- Rust 格式检查与 `git diff --check` 通过。 - -涉及监听器的测试在获准的沙箱外绑定本机回环端口;最初沙箱内的端口权限失败不作为 -功能失败,也未通过跳过测试来规避。所有 Cargo 测试使用 `--offline`,代理上游是 -本机 mock,不使用生产凭据。 - -复现命令: - -```bash -cargo test -p aether-gateway --lib --offline -- --quiet \ - handlers::proxy::websocket:: execution_runtime::transport::tests \ - execution_runtime::grok::tests execution_runtime::chatgpt_web_image::tests \ - test_connection bark_push::tests server_chan_push::tests \ - dns fake_ip benchmarking resolved_addrs email_delivery smtp -cargo test -p aether-tunnel --offline -- --quiet -cargo test -p aether-http --offline -``` diff --git a/docs/operations/legacy-policy-null-upgrade.md b/docs/operations/legacy-policy-null-upgrade.md deleted file mode 100644 index bdb83cf9a..000000000 --- a/docs/operations/legacy-policy-null-upgrade.md +++ /dev/null @@ -1,47 +0,0 @@ -# 历史权限空值升级修复 - -## 原因 - -旧版 API Key、用户、用户组及上游 Key 允许把 JSON 字面量 `null`、字符串 `"null"`(忽略大小写和首尾空白)以及空字符串作为未设置的权限。严格权限读取启用后,这些值会触发 `contains JSON null; use SQL NULL for an unset policy` 等错误,影响 API Key 鉴权、列表和用户、用户组读取。管理令牌的 IP 限制和权限也曾接受 JSON 字面量 `null`,但不接受字符串空值;严格校验同样会使这些旧令牌失效。 - -增量迁移 `20260908000000_normalize_legacy_policy_nulls.sql` 将这些已知的旧版空值转换成 SQL `NULL`,不修改既有迁移及其校验和。新安装和已有数据库升级均通过同一迁移机制执行。 - -## 覆盖范围 - -| 表 | 字段 | -| --- | --- | -| `api_keys` | `allowed_providers`、`allowed_api_formats`、`allowed_models`、`ip_rules` | -| `users` | `allowed_providers`、`allowed_api_formats`、`allowed_models` | -| `user_groups` | `allowed_providers`、`allowed_api_formats`、`allowed_models` | -| `provider_api_keys` | `api_formats`、`allowed_models` | -| `management_tokens` | `allowed_ips`、`permissions`(仅 JSON 字面量 `null`) | - -共 5 张表、14 个字段,同时兼容 `json` 和 `jsonb` 列。迁移可重复执行,且只更新命中旧版空值的字段: - -- 保留 SQL `NULL`、空数组 `[]`、正常名单、字符串化的名单和单字符串策略。 -- 保留 `specific`、`deny_all`、`inherit` 等权限模式,不修改用户组成员关系。 -- 管理令牌只转换 JSON 字面量 `null`,恢复旧版既有的未设置 IP 限制或 `legacy_full` 权限语义;字符串 `"null"`、空字符串、空数组和其他非法权限不转换,避免把原本无效的令牌权限变成旧版全权限。 -- 不清理数组内部的 `null`、空白元素、数字、对象或其他异常权限;它们仍由严格读取逻辑拒绝,不能借迁移变成无限制访问。 -- 不修改其他 JSON 字段,例如 `metadata` 内的 JSON `null`。 - -## 升级方式 - -先备份数据库,部署包含此迁移的新网关二进制或镜像,并保留原来的数据库连接配置与密钥。仅重启不包含此迁移的旧版本不会修复数据。 - -默认 `AETHER_GATEWAY_DATABASE_MODE=auto` 会在启动时执行挂起迁移,再进入正常服务。使用 `verify-only` 的部署需在相同数据库连接配置下先执行新版本的准备命令,再重启服务: - -```sh -aether-gateway db prepare -``` - -迁移只更新上述权限列,不删除业务记录、不替换用户或 Key、不重建数据库。不要把空数组或任意异常 JSON 统一改成 SQL `NULL`,也不要通过关闭严格权限校验绕过问题。 - -升级后可以只读检查迁移记录: - -```sql -SELECT version, description, success -FROM public._sqlx_migrations -WHERE version = 20260908000000; -``` - -该记录应存在且 `success = true`。若仍有权限解码错误,核对实际报错实例连接的数据库,以及是否有旧进程或外部工具继续写入旧格式;不要将其他类型的权限错误直接作为空值清除。 diff --git a/docs/operations/openai-responses-websocket-probe.md b/docs/operations/openai-responses-websocket-probe.md deleted file mode 100644 index b7a33c7d8..000000000 --- a/docs/operations/openai-responses-websocket-probe.md +++ /dev/null @@ -1,61 +0,0 @@ -# OpenAI Responses WebSocket probe - -`aether-openai-responses-ws-probe` verifies the official OpenAI Responses -WebSocket protocol using standard API-key Bearer authentication. It sends two -sequential `response.create` warmups on one socket, chaining the second from -the first response ID with `previous_response_id`. - -It shares its protocol-driving core with the Codex probe, but it does **not** -send Codex account headers or require Codex quota events. This makes it the -compatibility gate for Aether's standard Responses WebSocket adapter, rather -than a replacement for the Codex probe. - -## Prerequisites - -Use a dedicated API project and a model that your key can access. Keep values -only in your process environment or secret manager: - -```bash -export AETHER_OPENAI_WS_PROBE_API_KEY='your-api-key' -export AETHER_OPENAI_WS_PROBE_MODEL='your-openai-model' -``` - -The default endpoint is the official Responses WebSocket endpoint: - -```text -wss://api.openai.com/v1/responses -``` - -To test a compatible endpoint explicitly, set -`AETHER_OPENAI_WS_PROBE_URL` or pass `--url`. The endpoint must use `ws://` or -`wss://` and may not contain credentials, a query string, or a fragment. The -API key has no command-line flag and is never printed. - -## Run - -```bash -cargo run -p aether-gateway --bin aether-openai-responses-ws-probe -``` - -For an explicit endpoint and timeout: - -```bash -cargo run -p aether-gateway --bin aether-openai-responses-ws-probe -- \ - --url 'wss://api.openai.com/v1/responses' \ - --timeout-secs 30 -``` - -The probe uses `generate:false`, so the warmups prepare continuation state but -do not request model output. A successful JSON report contains -`"continuation_confirmed":true`; header and event arrays contain names only, -never credentials, response IDs, request bodies, or response bodies. - -## Interpretation - -Success establishes that this key, model, and endpoint support the Responses -WebSocket handshake plus an in-socket continuation. It does not establish -support for every model, tool, service tier, proxy path, or Aether provider -configuration. Treat a successful direct probe as a prerequisite before -enabling **Responses WebSocket mode** for the matching Aether provider. - -For protocol details, see the official [WebSocket Mode guide](https://developers.openai.com/api/docs/guides/websocket-mode). diff --git a/docs/operations/redis-runtime-runbook.md b/docs/operations/redis-runtime-runbook.md deleted file mode 100644 index 4d3184be6..000000000 --- a/docs/operations/redis-runtime-runbook.md +++ /dev/null @@ -1,150 +0,0 @@ -# Runtime Redis Operations Runbook - -This runbook covers Aether runtime Redis connection pressure incidents. It is -not a substitute for fixing application-level connection churn. - -## Persistence Policy - -The bundled `docker-compose.yml` treats Redis as a low-latency runtime -coordination layer by default: locks, cache affinity, semaphores, and runtime -streams. Postgres remains the source of truth. The default Redis persistence -policy is passed directly to `redis-server` in `docker-compose.yml`: - -```sh ---dir /tmp --appendonly no --save "" -``` - -This avoids request-path latency spikes from AOF fsync and background snapshot -forks. The trade-off is that Redis runtime state can be lost if the Redis -container or host crashes before workers have flushed queued records to the -database. The default `dir /tmp` also prevents old files in the mounted -data directory from being loaded as stale runtime state; the persistence -disable itself is `--appendonly no` and `--save ""`. - -Only deployments that intentionally want Redis runtime streams to survive a -crash should restore persistence in the Redis command: - -```sh ---dir /data --appendonly yes --appendfsync everysec --save 60 1000 -``` - -Expect higher tail latency when Redis persistence shares disks with Postgres or -application logs. - -### OpenAI Responses continuation history - -When an OpenAI Responses request is converted to an OpenAI Chat provider, -Aether stores the completed continuation transcript in `RuntimeState` under the -`ai:responses:history:v1` namespace. Records are immutable, scoped by a hashed -API key identity, limited to 8 MiB, and expire after six hours. Redis `SET` with -TTL makes completion writes atomic and idempotent. - -All gateway instances must use the same Redis URL and key prefix. This allows a -continuation request to land on another instance and allows gateway processes -to restart without losing history. `AETHER_RUNTIME_BACKEND=memory` remains a -single-process development mode and cannot provide either guarantee; multi-node -startup rejects it. - -The bundled non-persistent Redis policy survives gateway restarts but not a -Redis container or host restart. Deployments that require continuation history -to survive Redis restarts must enable the AOF/RDB policy above and mount `/data`, -or use an externally managed persistent Redis service. Monitor -`openai_response_history_read_failed`, `openai_response_history_write_failed`, -and `openai_response_history_invalid` events for backend or payload failures. - -## Latency Triage - -Redis `INFO commandstats` reports `latency_percentiles_usec_*` values in -microseconds. For example `p99=2007` means about 2 ms, not 2 seconds. - -Use these checks before attributing app stalls to Redis: - -```sh -redis-cli -p 6379 -a "$REDIS_PASSWORD" LATENCY DOCTOR -redis-cli -p 6379 -a "$REDIS_PASSWORD" LATENCY LATEST -redis-cli -p 6379 -a "$REDIS_PASSWORD" SLOWLOG GET 20 -redis-cli -p 6379 -a "$REDIS_PASSWORD" INFO persistence -redis-cli -p 6379 -a "$REDIS_PASSWORD" INFO commandstats -redis-cli -p 6379 -a "$REDIS_PASSWORD" INFO clients -``` - -For immediate mitigation on an existing container that is running with AOF -enabled: - -```sh -redis-cli -p 6379 -a "$REDIS_PASSWORD" CONFIG SET appendfsync no -redis-cli -p 6379 -a "$REDIS_PASSWORD" CONFIG SET appendonly no -redis-cli -p 6379 -a "$REDIS_PASSWORD" CONFIG SET save "" -``` - -Active defrag can help when `mem_fragmentation_ratio` is high, but it is not an -AOF fsync fix. Enable it only after confirming the Redis build supports it: - -```sh -redis-cli -p 6379 -a "$REDIS_PASSWORD" CONFIG SET activedefrag yes -redis-cli -p 6379 -a "$REDIS_PASSWORD" CONFIG SET active-defrag-ignore-bytes 50mb -redis-cli -p 6379 -a "$REDIS_PASSWORD" CONFIG SET active-defrag-threshold-lower 10 -``` - -## Normal Expectations - -- Each `RuntimeState` Redis backend initializes a fixed set of long-lived - connection lanes: fast, stream, blocking stream, and admin. -- `connected_clients` should stay near a small fixed number per app instance, - plus health checks and ad hoc admin clients. -- `total_connections_received` should not grow linearly with request volume. -- Large TIME_WAIT spikes between app and Redis indicate a regression or a - separate process repeatedly opening Redis connections. - -## Emergency Mitigation - -1. Disable the retry source first, such as expired Codex/OAuth keys causing a - retry storm. -2. Restart the app to stop continued connection creation: - - ```sh - docker compose restart app - ``` - -3. On a Linux host, temporarily widen the ephemeral port range and enable safe - TIME_WAIT reuse: - - ```sh - sudo sysctl -w net.ipv4.ip_local_port_range="10000 65535" - sudo sysctl -w net.ipv4.tcp_tw_reuse=1 - ``` - -4. Do not enable `tcp_tw_recycle`; it is obsolete and unsafe with NAT. - -Docker Desktop on macOS runs containers inside a Linux VM. Host-level macOS -`sysctl` changes do not necessarily affect the VM network namespace. - -## Checks - -Use Redis `INFO clients` and `INFO stats` to inspect: - -- `connected_clients` -- `total_connections_received` - -Use OS socket tooling on the Redis host or container namespace to inspect -TIME_WAIT counts. Persistent growth after the runtime Redis refactor means a -different code path or process is still opening short-lived Redis connections. - -## File Descriptor Limits - -Aether's compose files intentionally do not set container `ulimits.nofile`. -Redis connection churn must be fixed in application code, not hidden by larger -file descriptor limits. - -For high-concurrency production hosts, set file descriptor policy at the -runtime or service-manager layer instead: - -- Docker daemon default ulimit, for example `default-ulimits` in - `/etc/docker/daemon.json`. -- systemd service limits such as `LimitNOFILE=` for Docker or the process - supervisor. -- Managed container platform resource settings, when Docker daemon settings are - not available. - -Keep Redis `maxclients` below the effective Redis process `nofile` limit with -room for persistence files, replicas, and admin connections. diff --git a/docs/operations/routing-failover.md b/docs/operations/routing-failover.md deleted file mode 100644 index b021a7fbc..000000000 --- a/docs/operations/routing-failover.md +++ /dev/null @@ -1,57 +0,0 @@ -# 调度策略级故障转移 - -调度策略的 `default_policy` 支持跨提供商的转移预算与错误规则。它们跟随当前请求的 `routing_execution_policy` 快照进入执行器,不依赖运行中修改全局系统设置。已有策略缺省为不限次数、不限累计时间、无全局错误规则。 - -```json -{ - "default_policy": { - "sticky_key_attempts": 2, - "max_transfer_count": 3, - "max_transfer_timeout_seconds": 90, - "failover_rules": { - "success_failover_patterns": [ - { "pattern": "(?i)capacity.*exhausted" } - ], - "error_stop_patterns": [ - { "status_codes": [400, 413], "pattern": "invalid.*parameter" }, - { "status_codes": [422] } - ] - } - } -} -``` - -## 预算语义 - -- `sticky_key_attempts` 是首个粘性候选上的总尝试次数,`2` 表示首次请求加一次同 Key 重试。该行为保持不变。 -- 全局 `max_transfer_count` 统计切换候选的次数,首次尝试不计数。同一提供商、端点、Key 上的重试不计数;改变该组合计一次。`3` 最多允许首次候选之后再切换三次。 -- 全局 `max_transfer_timeout_seconds` 从首次候选开始执行时计时,覆盖后续重试与切换间的累计耗时。它在准备下一次尝试时检查,不会强制打断已经执行中的调用或已提交给客户端的流;单次连接、首字节、读取和非流式完整调用超时仍独立生效。 -- 两个全局预算的 `0` 都表示不限制。被筛除、禁用或被提供商级预算跳过而未执行的候选不计数。 -- 提供商自身的转移次数和时间预算继续生效。提供商预算耗尽只跳过该提供商,仍可尝试其他提供商;全局预算耗尽则不再执行任何提供商的新尝试。 -- 预算仅约束当前请求,不能重置或替代客户端取消策略、权限校验及本地执行异常的终止行为。 - -## 错误规则 - -先匹配调度策略的全局显式规则;未匹配时继续使用提供商的规则及协议默认行为。本地执行函数真正返回 `Err` 时仍然终止,不做兜底重放。 - -- **成功转移规则**:仅当 HTTP 200 响应匹配配置的正则时继续转移,不是对所有 200 进行重试。非流式请求匹配响应体;流式请求只匹配尚未交付业务输出的有界预读取内容。 -- **结构化错误优先**:标准流式请求统一在预读取阶段先解析完整 SSE 事件或 JSON 中的错误,再判断成功正则;不在半截错误载荷上提前触发成功转移,避免旧的 JSON 提前探测绕过错误终止规则。普通文本响应仍支持跨分片匹配。 -- **图片成功保护**:`openai:image` 的成功响应保留不重放行为,不因全局或提供商的成功正则再次生成图片;正常错误响应仍按错误规则处理。 -- **错误终止规则**:适用于 400–599 错误。状态码与正则都填写时要求同时满足;只填状态码表示该状态一律终止;只填正则表示在所有错误状态上匹配。流内错误使用解析后的错误状态,而不是外层 200。 -- **网络错误**:没有上游 HTTP 状态的连接、TLS、DNS、提交前超时等错误统一继续转移;提供商级别若单独配置了停止规则,仍按提供商规则处理。 -- 正则使用 Rust `regex` 语法,支持 `(?i)` 等内联标志。服务端拒绝无效正则、无意义的空规则以及错误状态范围。每组最多 64 条,每条表达式最多 4096 字节。 - -## 流式 200 的恢复窗口 - -上游 HTTP 200 响应头不再默认关闭标准文本 SSE 的恢复窗口。执行器先缓冲协议开场事件,例如 Responses 的 `response.created`、Chat 的 role-only 增量、Anthropic 的空 `message_start` / 文本块起始事件。`openai:image` 图片专用流保留原来的响应头提交行为,成功正则不打开重放窗口。 - -首个业务内容之前的结构化错误、过早 EOF、首字节超时以及 200 正则命中会进入统一故障转移判断。真实文本、思考、工具调用或正常结束事件确定后,缓冲内容按原顺序交付;之后发生的错误保持终止,不重新执行原请求。 - -预读取受单次首字节时间和既有字节上限约束。达到字节上限时保守提交,避免无限缓冲;这意味着不能承诺识别响应任意位置的错误或正则。原始 HTTP 状态与最终执行结果是不同观测值,不能因流内失败而伪改已经发送的 HTTP 状态码。 - -## 排查 - -- `routing_transfer_limit_reached`:当前调度策略的累计次数或时间预算耗尽。 -- `provider_transfer_limit_reached`:提供商自身的预算耗尽。 -- `local_stream_candidate_retry_scheduled`:输出前的流内错误或 200 规则触发了继续调度。 -- `local_stream_transport_retry_scheduled`:输出前传输错误触发了继续调度。 diff --git a/docs/operations/routing-scheduling.md b/docs/operations/routing-scheduling.md deleted file mode 100644 index ab836cb9f..000000000 --- a/docs/operations/routing-scheduling.md +++ /dev/null @@ -1,28 +0,0 @@ -# 按策略配置模型调度 - -管理端「调度策略 → 调度配置」按配置组织模型,不再逐个模型编辑和保存。 - -1. 先选择调度范围「全部模型」或「区分模型」。「全部模型」只显示一套调度设置,不显示模型选择和「添加配置」,保存后自动包含以后新增的模型。 -2. 「区分模型」下,在表单内展开适用模型列表并勾选模型,再设置调度优先级(Provider / Key)和调度策略(缓存亲和、负载均衡、固定顺序)。搜索只过滤模型列表,其他配置已占用的模型默认隐藏;通过取消勾选移除模型。选择框显示已选名称或数量,不重复显示已选标签。 -3. 设置此配置共用的提供商 / Key 排序。实际请求仍只使用该模型可用的候选,不会因为共用排序而启用不支持该模型的提供商。 -4. 「区分模型」下,点击调度配置标题旁的「添加配置」,为剩余模型选择不同策略。收起的配置直接显示适用模型名称,已分配给其他指定配置的模型不能重复选择。 -5. 点击页面顶部「保存」统一生效,不需要逐个模型另存草稿。指定范围为空时不能保存。 - -## 范围语义 - -- 「全部模型」是独立的动态范围,不是勾选当前列表的快捷操作,保存后也自动适用于之后新增的模型。「区分模型」的列表不提供「全部模型」选项。 -- 「全选当前」与「全选结果」只批量勾选当前可选模型,仍属于指定模型范围,不自动包含以后新增的模型。 -- 编辑期间切换范围会分别保留两种模式的草稿,切回后恢复原有选择和设置。首次切换时继承当前配置的调度和排序;从多配置首次切到全部模型时,优先继承默认配置,否则使用第一条配置。保存只写入当前模式,全部模型模式会清除模型级的自动生成规则和独立排序。 -- 旧数据中的指定模型与全部模型混合配置按「区分模型」加载,原全部模型条目显示为「默认配置」。默认排序仍可被各模型继承并覆盖,且不妨碍为剩余模型添加配置。 -- 没有全部模型配置时,未指定的模型继续使用已保存的默认调度设置,不会被禁用。先修改配置再改为指定范围,不会把修改后的调度模式应用到未选择的模型。 -- 移除模型或删除配置会同步移除对应的排序覆盖和自动生成的调度规则。 -- 故障转移、首个候选重试次数和客户端断开处理等仍作用于整个调度策略,不随模型范围拆分。 - -## 存储兼容 - -无需数据库迁移,继续使用 `default_policy`、`model_policies` 和 `rules`: - -- 全部模型的调度模式写入 `default_policy`,排序写入 `model: "*"` 的模型策略。 -- 指定范围的共用排序展开为各模型的 `model_policies`;同一配置使用一条 `ui_scheduling_policy:` 前缀的调度规则,通过 `conditions.any` 匹配适用模型,并使用 `set_scheduling` 设置调度模式。 -- 旧的逐模型排序和 `ui_model_scheduling:` 规则仍可读取;编辑调度配置时转换为新结构。等价的旧模型配置可合并显示,已有自定义规则和全局故障转移设置保留。 -- 新建的不同配置即使调度设置相同,也保留独立的配置范围,重新打开页面后仍可分别编辑。 diff --git a/docs/operations/security-hardening-audit-manifest.tsv b/docs/operations/security-hardening-audit-manifest.tsv deleted file mode 100644 index e1f967f44..000000000 --- a/docs/operations/security-hardening-audit-manifest.tsv +++ /dev/null @@ -1,1020 +0,0 @@ -path subsystem hardening_change lines_added lines_removed current_path post_hardening_HEAD heuristic_added_risk_lines -.env.example root-config M 39 7 present changed_after_hardening 0 -.github/workflows/build-tunnel.yml .github M 34 10 present unchanged_at_HEAD 0 -.github/workflows/deploy-pages.yml .github M 8 7 present unchanged_at_HEAD 0 -.github/workflows/nightly.yml .github M 24 24 present changed_after_hardening 0 -.github/workflows/release.yml .github M 75 27 present unchanged_at_HEAD 0 -.github/workflows/rust-ci.yml .github M 112 56 present changed_after_hardening 0 -Cargo.lock root-config M 591 342 present changed_after_hardening 0 -Cargo.toml root-config M 3 1 present changed_after_hardening 0 -Dockerfile.app root-config M 9 4 present unchanged_at_HEAD 0 -README.md root-config M 54 9 present changed_after_hardening 0 -apps/aether-gateway/Cargo.toml apps M 3 2 present changed_after_hardening 0 -apps/aether-gateway/examples/execution_runtime_harness.rs apps M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/admin_api.rs apps/aether-gateway/src/admin_api.rs M 9 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/ai_serving/adaptation/private_envelope/sync.rs apps/aether-gateway/src/ai_serving M 3 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs apps/aether-gateway/src/ai_serving M 13 30 present unchanged_at_HEAD 0 -apps/aether-gateway/src/ai_serving/planner/candidate_metadata.rs apps/aether-gateway/src/ai_serving M 10 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs apps/aether-gateway/src/ai_serving M 32 20 present unchanged_at_HEAD 1 -apps/aether-gateway/src/ai_serving/planner/candidate_source.rs apps/aether-gateway/src/ai_serving M 17 3 present unchanged_at_HEAD 1 -apps/aether-gateway/src/ai_serving/planner/decision/stream.rs apps/aether-gateway/src/ai_serving M 31 9 present unchanged_at_HEAD 0 -apps/aether-gateway/src/ai_serving/planner/decision/sync.rs apps/aether-gateway/src/ai_serving M 19 9 present unchanged_at_HEAD 0 -apps/aether-gateway/src/ai_serving/planner/decision_input.rs apps/aether-gateway/src/ai_serving M 76 14 present changed_after_hardening 8 -apps/aether-gateway/src/ai_serving/planner/report_context.rs apps/aether-gateway/src/ai_serving M 48 8 present unchanged_at_HEAD 3 -apps/aether-gateway/src/ai_serving/planner/specialized/files/request.rs apps/aether-gateway/src/ai_serving M 85 1 present unchanged_at_HEAD 3 -apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs apps/aether-gateway/src/ai_serving M 1 1 present changed_after_hardening 0 -apps/aether-gateway/src/ai_serving/planner/standard/openai/plan_builders/stream.rs apps/aether-gateway/src/ai_serving M 12 27 present unchanged_at_HEAD 1 -apps/aether-gateway/src/ai_serving/planner/standard/openai/plan_builders/sync.rs apps/aether-gateway/src/ai_serving M 12 27 present unchanged_at_HEAD 1 -apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/payload.rs apps/aether-gateway/src/ai_serving M 5 5 present unchanged_at_HEAD 1 -apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs apps/aether-gateway/src/ai_serving M 7 15 present unchanged_at_HEAD 1 -apps/aether-gateway/src/ai_serving/response_history.rs apps/aether-gateway/src/ai_serving M 160 8 present unchanged_at_HEAD 1 -apps/aether-gateway/src/api/core.rs apps/aether-gateway/src/api M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/api/ops.rs apps/aether-gateway/src/api M 254 3 present unchanged_at_HEAD 11 -apps/aether-gateway/src/api/response.rs apps/aether-gateway/src/api M 301 13 present unchanged_at_HEAD 1 -apps/aether-gateway/src/async_task/http.rs apps/aether-gateway/src/async_task M 331 42 present unchanged_at_HEAD 6 -apps/aether-gateway/src/async_task/http/cancel.rs apps/aether-gateway/src/async_task M 189 108 present unchanged_at_HEAD 9 -apps/aether-gateway/src/async_task/mod.rs apps/aether-gateway/src/async_task M 6 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/async_task/query.rs apps/aether-gateway/src/async_task M 263 5 present unchanged_at_HEAD 12 -apps/aether-gateway/src/async_task/runtime.rs apps/aether-gateway/src/async_task M 79 164 present unchanged_at_HEAD 13 -apps/aether-gateway/src/audit/admin.rs apps/aether-gateway/src/audit M 48 4 present unchanged_at_HEAD 15 -apps/aether-gateway/src/audit/http.rs apps/aether-gateway/src/audit M 8 2 present unchanged_at_HEAD 2 -apps/aether-gateway/src/backup/config.rs apps/aether-gateway/src/backup M 137 3 present unchanged_at_HEAD 2 -apps/aether-gateway/src/backup/executor.rs apps/aether-gateway/src/backup M 1086 29 present unchanged_at_HEAD 4 -apps/aether-gateway/src/backup/mod.rs apps/aether-gateway/src/backup M 162 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/backup/scopes.rs apps/aether-gateway/src/backup M 153 15 present unchanged_at_HEAD 2 -apps/aether-gateway/src/backup/store.rs apps/aether-gateway/src/backup M 219 27 present unchanged_at_HEAD 0 -apps/aether-gateway/src/backup/task.rs apps/aether-gateway/src/backup M 410 55 present unchanged_at_HEAD 2 -apps/aether-gateway/src/backup/worker.rs apps/aether-gateway/src/backup M 20 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/bark_push.rs apps/aether-gateway/src/bark_push.rs M 321 41 present changed_after_hardening 2 -apps/aether-gateway/src/bin/aether-backup-restore.rs apps/aether-gateway/src/bin A 1047 0 present unchanged_at_HEAD 3 -apps/aether-gateway/src/bin/support/responses_ws_probe.rs apps/aether-gateway/src/bin M 9 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/cache/auth_context.rs apps/aether-gateway/src/cache M 1 0 present unchanged_at_HEAD 1 -apps/aether-gateway/src/cache/system_config.rs apps/aether-gateway/src/cache M 9 0 present unchanged_at_HEAD 1 -apps/aether-gateway/src/constants.rs apps/aether-gateway/src/constants.rs M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/auth/credentials.rs apps/aether-gateway/src/control M 158 20 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/auth/gate.rs apps/aether-gateway/src/control M 2 1 present unchanged_at_HEAD 1 -apps/aether-gateway/src/control/auth/mod.rs apps/aether-gateway/src/control M 8 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/auth/resolution.rs apps/aether-gateway/src/control M 337 120 present changed_after_hardening 19 -apps/aether-gateway/src/control/auth/types.rs apps/aether-gateway/src/control M 91 3 present unchanged_at_HEAD 14 -apps/aether-gateway/src/control/management_token_permissions.rs apps/aether-gateway/src/control M 1210 28 present unchanged_at_HEAD 4 -apps/aether-gateway/src/control/mod.rs apps/aether-gateway/src/control M 10 7 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/public.rs apps/aether-gateway/src/control M 41 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/route/internal.rs apps/aether-gateway/src/control M 15 13 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/route/mod.rs apps/aether-gateway/src/control M 22 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/route/oauth.rs apps/aether-gateway/src/control M 3 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/route/public_support.rs apps/aether-gateway/src/control M 7 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/tests/admin_core.rs apps/aether-gateway/src/control M 2 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/tests/admin_oauth.rs apps/aether-gateway/src/control M 16 16 present unchanged_at_HEAD 0 -apps/aether-gateway/src/control/tests/public_support.rs apps/aether-gateway/src/control M 28 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/data/decision_trace.rs apps/aether-gateway/src/data M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/data/state/auth.rs apps/aether-gateway/src/data M 1345 79 present changed_after_hardening 17 -apps/aether-gateway/src/data/state/catalog.rs apps/aether-gateway/src/data M 275 47 present changed_after_hardening 18 -apps/aether-gateway/src/data/state/core.rs apps/aether-gateway/src/data M 38 0 present changed_after_hardening 0 -apps/aether-gateway/src/data/state/integrations.rs apps/aether-gateway/src/data M 89 13 present unchanged_at_HEAD 3 -apps/aether-gateway/src/data/state/mod.rs apps/aether-gateway/src/data M 35 25 present unchanged_at_HEAD 0 -apps/aether-gateway/src/data/state/referrals.rs apps/aether-gateway/src/data M 12 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/data/state/runtime.rs apps/aether-gateway/src/data M 257 19 present unchanged_at_HEAD 0 -apps/aether-gateway/src/data/state/testing/mod.rs apps/aether-gateway/src/data M 42 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/data/state/testkit.rs apps/aether-gateway/src/data M 103 0 present unchanged_at_HEAD 2 -apps/aether-gateway/src/data/tests.rs apps/aether-gateway/src/data M 1 1 present changed_after_hardening 0 -apps/aether-gateway/src/dispatch/pool_scheduler.rs apps/aether-gateway/src/dispatch M 22 2 present unchanged_at_HEAD 1 -apps/aether-gateway/src/email_delivery.rs apps/aether-gateway/src/email_delivery.rs M 528 45 present changed_after_hardening 22 -apps/aether-gateway/src/error.rs apps/aether-gateway/src/error.rs M 97 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/execution_runtime/attempt_lifecycle.rs apps/aether-gateway/src/execution_runtime M 36 11 present changed_after_hardening 1 -apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs apps/aether-gateway/src/execution_runtime M 1622 232 present changed_after_hardening 19 -apps/aether-gateway/src/execution_runtime/constants.rs apps/aether-gateway/src/execution_runtime M 10 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/execution_runtime/fallback.rs apps/aether-gateway/src/execution_runtime M 12 2 present changed_after_hardening 1 -apps/aether-gateway/src/execution_runtime/grok.rs apps/aether-gateway/src/execution_runtime M 1358 319 present unchanged_at_HEAD 20 -apps/aether-gateway/src/execution_runtime/kiro_web_search.rs apps/aether-gateway/src/execution_runtime M 114 44 present unchanged_at_HEAD 8 -apps/aether-gateway/src/execution_runtime/mod.rs apps/aether-gateway/src/execution_runtime M 2 1 present changed_after_hardening 0 -apps/aether-gateway/src/execution_runtime/remote_compat.rs apps/aether-gateway/src/execution_runtime M 75 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/execution_runtime/server.rs apps/aether-gateway/src/execution_runtime M 933 37 present changed_after_hardening 1 -apps/aether-gateway/src/execution_runtime/stream/error.rs apps/aether-gateway/src/execution_runtime M 76 11 present unchanged_at_HEAD 2 -apps/aether-gateway/src/execution_runtime/stream/execution.rs apps/aether-gateway/src/execution_runtime M 451 199 present changed_after_hardening 3 -apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs apps/aether-gateway/src/execution_runtime M 142 18 present unchanged_at_HEAD 1 -apps/aether-gateway/src/execution_runtime/stream_pump.rs apps/aether-gateway/src/execution_runtime M 192 100 present unchanged_at_HEAD 1 -apps/aether-gateway/src/execution_runtime/submission.rs apps/aether-gateway/src/execution_runtime M 7 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/execution_runtime/sync/execution.rs apps/aether-gateway/src/execution_runtime M 156 26 present unchanged_at_HEAD 3 -apps/aether-gateway/src/execution_runtime/sync/execution/policy.rs apps/aether-gateway/src/execution_runtime M 55 8 present unchanged_at_HEAD 2 -apps/aether-gateway/src/execution_runtime/sync/execution/response.rs apps/aether-gateway/src/execution_runtime M 78 40 present unchanged_at_HEAD 0 -apps/aether-gateway/src/execution_runtime/transport.rs apps/aether-gateway/src/execution_runtime M 2053 238 present changed_after_hardening 84 -apps/aether-gateway/src/execution_runtime/windsurf.rs apps/aether-gateway/src/execution_runtime M 767 217 present unchanged_at_HEAD 16 -apps/aether-gateway/src/executor/candidate_loop.rs apps/aether-gateway/src/executor M 784 45 present changed_after_hardening 26 -apps/aether-gateway/src/executor/mod.rs apps/aether-gateway/src/executor M 5 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/executor/orchestration.rs apps/aether-gateway/src/executor M 74 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/executor/outcome.rs apps/aether-gateway/src/executor M 79 118 present unchanged_at_HEAD 3 -apps/aether-gateway/src/executor/stream_path.rs apps/aether-gateway/src/executor M 56 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/executor/sync_path.rs apps/aether-gateway/src/executor M 81 17 present unchanged_at_HEAD 0 -apps/aether-gateway/src/frontdoor_loop_guard.rs apps/aether-gateway/src/frontdoor_loop_guard.rs M 25 4 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/auth/api_keys/install_routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 15 32 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/api_keys/mod.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 3 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/api_keys/mutation_routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 246 33 present unchanged_at_HEAD 13 -apps/aether-gateway/src/handlers/admin/auth/api_keys/read_routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 22 15 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/api_keys/routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 2 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/api_keys/shared.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 7 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/ldap/builders.rs apps/aether-gateway/src/handlers/admin/auth/ldap M 141 102 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/auth/ldap/routes.rs apps/aether-gateway/src/handlers/admin/auth/ldap M 14 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/ldap/shared.rs apps/aether-gateway/src/handlers/admin/auth/ldap M 13 9 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/oauth_config.rs apps/aether-gateway/src/handlers/admin/auth/oauth_config.rs M 121 64 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs M 265 83 present changed_after_hardening 9 -apps/aether-gateway/src/handlers/admin/auth/routes.rs apps/aether-gateway/src/handlers/admin/auth/routes.rs M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/collectors/support.rs apps/aether-gateway/src/handlers/admin/billing/collectors M 13 12 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/mod.rs apps/aether-gateway/src/handlers/admin/billing/mod.rs M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/payments/gateways.rs apps/aether-gateway/src/handlers/admin/billing/payments M 446 100 present unchanged_at_HEAD 4 -apps/aether-gateway/src/handlers/admin/billing/payments/mod.rs apps/aether-gateway/src/handlers/admin/billing/payments M 3 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/payments/orders.rs apps/aether-gateway/src/handlers/admin/billing/payments M 8 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/payments/redeem_codes.rs apps/aether-gateway/src/handlers/admin/billing/payments M 3 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/payments/shared.rs apps/aether-gateway/src/handlers/admin/billing/payments M 388 13 present unchanged_at_HEAD 18 -apps/aether-gateway/src/handlers/admin/billing/plans.rs apps/aether-gateway/src/handlers/admin/billing/plans.rs M 15 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/rules.rs apps/aether-gateway/src/handlers/admin/billing/rules.rs M 2 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/adjust.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/complete_refund.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 252 13 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/fail_refund.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/process_refund.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/recharge.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/reads/ledger.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/reads/list.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/reads/refund_requests.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 9 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/reads/transactions.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/shared/normalizers.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/billing/wallets/shared/payloads.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 189 5 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/features/background_tasks/routes.rs apps/aether-gateway/src/handlers/admin/features/background_tasks M 92 35 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/features/gemini_files/read_routes.rs apps/aether-gateway/src/handlers/admin/features/gemini_files M 1 0 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/features/gemini_files/upload/request.rs apps/aether-gateway/src/handlers/admin/features/gemini_files M 434 48 present unchanged_at_HEAD 12 -apps/aether-gateway/src/handlers/admin/features/gemini_files/upload/stage.rs apps/aether-gateway/src/handlers/admin/features/gemini_files M 144 13 present unchanged_at_HEAD 10 -apps/aether-gateway/src/handlers/admin/features/video_tasks/builders.rs apps/aether-gateway/src/handlers/admin/features/video_tasks M 77 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/features/video_tasks/routes.rs apps/aether-gateway/src/handlers/admin/features/video_tasks M 8 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/mod.rs apps/aether-gateway/src/handlers/admin/mod.rs M 6 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/model/external_cache.rs apps/aether-gateway/src/handlers/admin/model/external_cache.rs M 259 26 present changed_after_hardening 2 -apps/aether-gateway/src/handlers/admin/model/global_models/routes/core/writes.rs apps/aether-gateway/src/handlers/admin/model/global_models M 84 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_affinity_reads.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 2 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_payloads.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 51 66 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience/snapshot.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 6 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/mod.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 19 9 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 36 56 present changed_after_hardening 0 -apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 23 36 present unchanged_at_HEAD 6 -apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs apps/aether-gateway/src/handlers/admin/observability/usage M 8 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs apps/aether-gateway/src/handlers/admin/observability/usage M 33 9 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/admin/provider/delete_task.rs apps/aether-gateway/src/handlers/admin/provider/delete_task.rs M 8 19 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/endpoint_keys/reads.rs apps/aether-gateway/src/handlers/admin/provider/endpoint_keys M 7 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/models/batch.rs apps/aether-gateway/src/handlers/admin/provider/models M 75 15 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/execution.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 2 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 8 24 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/task.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 14 15 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/complete/key.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 59 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/complete/provider.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 43 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/complete/shared.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 84 0 present unchanged_at_HEAD 5 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/authorize.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 26 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/lease.rs apps/aether-gateway/src/handlers/admin/provider/oauth A 277 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/mod.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/poll.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 527 375 present unchanged_at_HEAD 35 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/session.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/import.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 5 10 present changed_after_hardening 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/kiro.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 43 14 present unchanged_at_HEAD 14 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/execution.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 11 15 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/helpers.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 8 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/request.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 2 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/start.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 49 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/token_import.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 34 6 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/admin/provider/oauth/duplicates.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 3 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/errors.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 12 23 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/provisioning.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 24 14 present changed_after_hardening 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/antigravity.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 14 21 present changed_after_hardening 1 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 94 29 present unchanged_at_HEAD 11 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 121 43 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/gemini_cli.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 12 18 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/grok.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 88 25 present unchanged_at_HEAD 11 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/kiro/mod.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 90 81 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 529 202 present changed_after_hardening 20 -apps/aether-gateway/src/handlers/admin/provider/oauth/quota/windsurf.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 25 99 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/admin/provider/oauth/state/exchange.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 19 6 present changed_after_hardening 0 -apps/aether-gateway/src/handlers/admin/provider/oauth/state/storage.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 7 36 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/checkin/probe.rs apps/aether-gateway/src/handlers/admin/provider/ops M 18 25 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/checkin/run.rs apps/aether-gateway/src/handlers/admin/provider/ops M 16 24 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/checkin/shared.rs apps/aether-gateway/src/handlers/admin/provider/ops M 41 26 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 18 16 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/query_balance/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 12 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/query_balance/sub2api.rs apps/aether-gateway/src/handlers/admin/provider/ops M 23 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/query_balance/yescode.rs apps/aether-gateway/src/handlers/admin/provider/ops M 41 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/support.rs apps/aether-gateway/src/handlers/admin/provider/ops M 7 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/balance_cache.rs apps/aether-gateway/src/handlers/admin/provider/ops M 349 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/config.rs apps/aether-gateway/src/handlers/admin/provider/ops M 502 130 present unchanged_at_HEAD 8 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/batch.rs apps/aether-gateway/src/handlers/admin/provider/ops M 44 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/config.rs apps/aether-gateway/src/handlers/admin/provider/ops M 104 42 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/connect.rs apps/aether-gateway/src/handlers/admin/provider/ops M 6 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/read.rs apps/aether-gateway/src/handlers/admin/provider/ops M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/verify.rs apps/aether-gateway/src/handlers/admin/provider/ops M 94 24 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/support.rs apps/aether-gateway/src/handlers/admin/provider/ops M 6 18 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/verify/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 12 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/verify/request.rs apps/aether-gateway/src/handlers/admin/provider/ops M 43 45 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/ops/providers/verify/sub2api.rs apps/aether-gateway/src/handlers/admin/provider/ops M 15 21 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/pool/runtime/keys.rs apps/aether-gateway/src/handlers/admin/provider/pool M 27 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/pool_admin/batch_routes/action.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 26 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/pool_admin/batch_routes/update.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 8 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 39 9 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/pool_admin/read_routes/scores.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 5 1 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/pool_admin/selection.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 5 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/query/models/mod.rs apps/aether-gateway/src/handlers/admin/provider/query M 40 3 present changed_after_hardening 0 -apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs apps/aether-gateway/src/handlers/admin/provider/query M 17 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs apps/aether-gateway/src/handlers/admin/provider/query M 503 13 present unchanged_at_HEAD 28 -apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs apps/aether-gateway/src/handlers/admin/provider/query M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/shared/payloads.rs apps/aether-gateway/src/handlers/admin/provider/shared M 100 3 present unchanged_at_HEAD 10 -apps/aether-gateway/src/handlers/admin/provider/summary/health.rs apps/aether-gateway/src/handlers/admin/provider/summary M 3 3 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/admin/provider/summary/list.rs apps/aether-gateway/src/handlers/admin/provider/summary M 4 1 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/summary/value.rs apps/aether-gateway/src/handlers/admin/provider/summary M 6 5 present changed_after_hardening 2 -apps/aether-gateway/src/handlers/admin/provider/write/keys/create.rs apps/aether-gateway/src/handlers/admin/provider/write M 16 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/provider/write/keys/update.rs apps/aether-gateway/src/handlers/admin/provider/write M 46 20 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/write/provider/update.rs apps/aether-gateway/src/handlers/admin/provider/write M 27 1 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/provider/write/reveal.rs apps/aether-gateway/src/handlers/admin/provider/write M 19 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/auth.rs apps/aether-gateway/src/handlers/admin/request/auth.rs M 44 7 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/capabilities.rs apps/aether-gateway/src/handlers/admin/request/capabilities.rs M 12 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/mod.rs apps/aether-gateway/src/handlers/admin/request/mod.rs M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/models.rs apps/aether-gateway/src/handlers/admin/request/models.rs M 25 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/provider/catalog.rs apps/aether-gateway/src/handlers/admin/request/provider M 9 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/provider/oauth.rs apps/aether-gateway/src/handlers/admin/request/provider M 593 93 present unchanged_at_HEAD 16 -apps/aether-gateway/src/handlers/admin/request/provider/tasks.rs apps/aether-gateway/src/handlers/admin/request/provider M 76 19 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/request/provider/transport.rs apps/aether-gateway/src/handlers/admin/request/provider M 96 30 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/admin/request/route_request.rs apps/aether-gateway/src/handlers/admin/request/route_request.rs M 8 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/system/export.rs apps/aether-gateway/src/handlers/admin/request/system M 596 101 present unchanged_at_HEAD 21 -apps/aether-gateway/src/handlers/admin/request/system/import.rs apps/aether-gateway/src/handlers/admin/request/system M 5994 529 present changed_after_hardening 107 -apps/aether-gateway/src/handlers/admin/request/system/mod.rs apps/aether-gateway/src/handlers/admin/request/system M 5 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/request/users.rs apps/aether-gateway/src/handlers/admin/request/users.rs M 153 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/shared/mod.rs apps/aether-gateway/src/handlers/admin/shared/mod.rs M 7 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/core/system_routes.rs apps/aether-gateway/src/handlers/admin/system/core M 207 73 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/import_lock.rs apps/aether-gateway/src/handlers/admin/system/import_lock.rs A 325 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/management_tokens.rs apps/aether-gateway/src/handlers/admin/system/management_tokens.rs M 93 44 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/mod.rs apps/aether-gateway/src/handlers/admin/system/mod.rs M 5 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs M 398 131 present changed_after_hardening 12 -apps/aether-gateway/src/handlers/admin/system/routes.rs apps/aether-gateway/src/handlers/admin/system/routes.rs M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/shared/configs.rs apps/aether-gateway/src/handlers/admin/system/shared M 63 9 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/shared/export/mod.rs apps/aether-gateway/src/handlers/admin/system/shared M 3 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/shared/export/providers.rs apps/aether-gateway/src/handlers/admin/system/shared M 245 179 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/admin/system/shared/export/support.rs apps/aether-gateway/src/handlers/admin/system/shared M 107 15 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/admin/system/shared/modules.rs apps/aether-gateway/src/handlers/admin/system/shared M 1 1 present changed_after_hardening 0 -apps/aether-gateway/src/handlers/admin/system/shared/settings.rs apps/aether-gateway/src/handlers/admin/system/shared M 13 8 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/admin/system/shared/smtp.rs apps/aether-gateway/src/handlers/admin/system/shared M 68 38 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/system/shared/update.rs apps/aether-gateway/src/handlers/admin/system/shared M 1731 183 present changed_after_hardening 34 -apps/aether-gateway/src/handlers/admin/system/shared/update_client.rs apps/aether-gateway/src/handlers/admin/system/shared M 149 3 present changed_after_hardening 2 -apps/aether-gateway/src/handlers/admin/users/api_keys/helpers.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 7 12 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/api_keys/responses/create.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 38 55 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/api_keys/responses/list.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/api_keys/responses/reveal.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 19 12 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/api_keys/responses/update.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 9 8 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/admin/users/batch.rs apps/aether-gateway/src/handlers/admin/users/batch.rs M 21 6 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/users/billing.rs apps/aether-gateway/src/handlers/admin/users/billing.rs M 9 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/lifecycle/create.rs apps/aether-gateway/src/handlers/admin/users/lifecycle M 151 16 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/users/lifecycle/delete.rs apps/aether-gateway/src/handlers/admin/users/lifecycle M 17 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/lifecycle/update.rs apps/aether-gateway/src/handlers/admin/users/lifecycle M 60 12 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/users/mod.rs apps/aether-gateway/src/handlers/admin/users/mod.rs M 4 3 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/admin/users/sessions.rs apps/aether-gateway/src/handlers/admin/users/sessions.rs M 20 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/admin/users/shared.rs apps/aether-gateway/src/handlers/admin/users/shared.rs M 37 2 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/internal/gateway.rs apps/aether-gateway/src/handlers M 341 106 present unchanged_at_HEAD 7 -apps/aether-gateway/src/handlers/internal/gateway_helpers.rs apps/aether-gateway/src/handlers M 35 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/body_buffer.rs apps/aether-gateway/src/handlers M 91 10 present unchanged_at_HEAD 6 -apps/aether-gateway/src/handlers/proxy/finalize.rs apps/aether-gateway/src/handlers M 139 1 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/proxy/local.rs apps/aether-gateway/src/handlers M 4 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/mod.rs apps/aether-gateway/src/handlers M 806 238 present unchanged_at_HEAD 16 -apps/aether-gateway/src/handlers/proxy/websocket/live/audit.rs apps/aether-gateway/src/handlers M 2 0 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/proxy/websocket/live/http.rs apps/aether-gateway/src/handlers M 27 5 present changed_after_hardening 3 -apps/aether-gateway/src/handlers/proxy/websocket/live/session.rs apps/aether-gateway/src/handlers M 20 0 present changed_after_hardening 1 -apps/aether-gateway/src/handlers/proxy/websocket/realtime/session.rs apps/aether-gateway/src/handlers M 7 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/client.rs apps/aether-gateway/src/handlers M 178 60 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/connection.rs apps/aether-gateway/src/handlers M 65 27 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/proxy/websocket/responses/lifecycle.rs apps/aether-gateway/src/handlers M 52 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/mod.rs apps/aether-gateway/src/handlers M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/ownership.rs apps/aether-gateway/src/handlers M 3 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/plan_admission.rs apps/aether-gateway/src/handlers A 232 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/quota.rs apps/aether-gateway/src/handlers M 56 15 present unchanged_at_HEAD 10 -apps/aether-gateway/src/handlers/proxy/websocket/responses/redaction.rs apps/aether-gateway/src/handlers M 67 11 present unchanged_at_HEAD 11 -apps/aether-gateway/src/handlers/proxy/websocket/responses/session.rs apps/aether-gateway/src/handlers M 105 31 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/settlement.rs apps/aether-gateway/src/handlers M 24 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs apps/aether-gateway/src/handlers M 433 14 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/proxy/websocket/responses/turn_state.rs apps/aether-gateway/src/handlers M 28 1 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/proxy/websocket/responses/upstream.rs apps/aether-gateway/src/handlers M 308 26 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/proxy/websocket/transport.rs apps/aether-gateway/src/handlers M 243 25 present changed_after_hardening 2 -apps/aether-gateway/src/handlers/public/ai_public.rs apps/aether-gateway/src/handlers M 821 81 present unchanged_at_HEAD 10 -apps/aether-gateway/src/handlers/public/catalog_helpers.rs apps/aether-gateway/src/handlers M 502 48 present unchanged_at_HEAD 16 -apps/aether-gateway/src/handlers/public/mod.rs apps/aether-gateway/src/handlers M 3 2 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/public/support.rs apps/aether-gateway/src/handlers M 71 36 present unchanged_at_HEAD 5 -apps/aether-gateway/src/handlers/public/support/announcements/public_routes.rs apps/aether-gateway/src/handlers M 10 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/announcements/shared.rs apps/aether-gateway/src/handlers M 15 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs apps/aether-gateway/src/handlers M 4 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/auth.rs apps/aether-gateway/src/handlers M 172 82 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/auth_email.rs apps/aether-gateway/src/handlers M 237 22 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/public/support/auth_helpers.rs apps/aether-gateway/src/handlers M 236 61 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/public/support/auth_ldap.rs apps/aether-gateway/src/handlers M 11 31 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/auth_rate_limit.rs apps/aether-gateway/src/handlers A 318 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/auth_registration.rs apps/aether-gateway/src/handlers M 405 157 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/auth_session.rs apps/aether-gateway/src/handlers M 276 237 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/auth_turnstile.rs apps/aether-gateway/src/handlers M 29 20 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/billing.rs apps/aether-gateway/src/handlers M 1352 151 present changed_after_hardening 8 -apps/aether-gateway/src/handlers/public/support/install.rs apps/aether-gateway/src/handlers M 796 93 present changed_after_hardening 8 -apps/aether-gateway/src/handlers/public/support/monitoring/audit_logs.rs apps/aether-gateway/src/handlers M 2 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/oauth.rs apps/aether-gateway/src/handlers M 586 220 present changed_after_hardening 2 -apps/aether-gateway/src/handlers/public/support/payment.rs apps/aether-gateway/src/handlers M 39 4 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/public/support/payment/alipay.rs apps/aether-gateway/src/handlers M 29 22 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/payment/epay.rs apps/aether-gateway/src/handlers M 322 109 present unchanged_at_HEAD 10 -apps/aether-gateway/src/handlers/public/support/payment/repository.rs apps/aether-gateway/src/handlers M 141 89 present unchanged_at_HEAD 4 -apps/aether-gateway/src/handlers/public/support/payment/route.rs apps/aether-gateway/src/handlers M 35 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/payment/shared.rs apps/aether-gateway/src/handlers M 266 50 present unchanged_at_HEAD 10 -apps/aether-gateway/src/handlers/public/support/payment/stripe.rs apps/aether-gateway/src/handlers M 113 55 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/public/support/payment/test_support.rs apps/aether-gateway/src/handlers M 14 86 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/public/support/payment/wxpay.rs apps/aether-gateway/src/handlers M 39 23 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/test_connection.rs apps/aether-gateway/src/handlers M 20 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/test_connection/route.rs apps/aether-gateway/src/handlers M 290 13 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/user_me.rs apps/aether-gateway/src/handlers M 6 4 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/public/support/user_me_api_keys.rs apps/aether-gateway/src/handlers M 124 110 present unchanged_at_HEAD 3 -apps/aether-gateway/src/handlers/public/support/user_me_catalog.rs apps/aether-gateway/src/handlers M 15 6 present unchanged_at_HEAD 5 -apps/aether-gateway/src/handlers/public/support/user_me_management_tokens.rs apps/aether-gateway/src/handlers M 99 29 present unchanged_at_HEAD 7 -apps/aether-gateway/src/handlers/public/support/user_me_profile.rs apps/aether-gateway/src/handlers M 129 65 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/user_me_routes.rs apps/aether-gateway/src/handlers M 26 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/user_me_sessions.rs apps/aether-gateway/src/handlers M 9 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/user_me_usage.rs apps/aether-gateway/src/handlers M 117 15 present changed_after_hardening 1 -apps/aether-gateway/src/handlers/public/support/wallet.rs apps/aether-gateway/src/handlers M 14 14 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/public/support/wallet/reads.rs apps/aether-gateway/src/handlers M 5 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/wallet/recharge.rs apps/aether-gateway/src/handlers M 2874 190 present unchanged_at_HEAD 41 -apps/aether-gateway/src/handlers/public/support/wallet/redeem.rs apps/aether-gateway/src/handlers M 3 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/support/wallet/refunds.rs apps/aether-gateway/src/handlers M 97 9 present unchanged_at_HEAD 6 -apps/aether-gateway/src/handlers/public/support/wallet/test_support.rs apps/aether-gateway/src/handlers M 37 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/public/system_modules_helpers/modules.rs apps/aether-gateway/src/handlers M 1 12 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/admin_proxy.rs apps/aether-gateway/src/handlers M 36 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/api_keys.rs apps/aether-gateway/src/handlers M 46 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/auth_api_key_secret.rs apps/aether-gateway/src/handlers A 359 0 present unchanged_at_HEAD 15 -apps/aether-gateway/src/handlers/shared/catalog.rs apps/aether-gateway/src/handlers M 210 40 present unchanged_at_HEAD 6 -apps/aether-gateway/src/handlers/shared/email_templates.rs apps/aether-gateway/src/handlers M 90 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/identity_oauth_provider_secret.rs apps/aether-gateway/src/handlers A 634 0 present unchanged_at_HEAD 7 -apps/aether-gateway/src/handlers/shared/mod.rs apps/aether-gateway/src/handlers M 73 14 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/shared/multipart.rs apps/aether-gateway/src/handlers A 350 0 present unchanged_at_HEAD 11 -apps/aether-gateway/src/handlers/shared/normalize.rs apps/aether-gateway/src/handlers M 22 2 present unchanged_at_HEAD 4 -apps/aether-gateway/src/handlers/shared/payloads.rs apps/aether-gateway/src/handlers M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/payment_currency.rs apps/aether-gateway/src/handlers A 101 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/payment_direct.rs apps/aether-gateway/src/handlers M 1256 311 present changed_after_hardening 10 -apps/aether-gateway/src/handlers/shared/payment_gateway_config.rs apps/aether-gateway/src/handlers M 77 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/handlers/shared/payment_gateway_secret.rs apps/aether-gateway/src/handlers A 428 0 present unchanged_at_HEAD 5 -apps/aether-gateway/src/handlers/shared/payment_order_stripe_secret.rs apps/aether-gateway/src/handlers A 278 0 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/shared/provider_catalog_credential.rs apps/aether-gateway/src/handlers A 281 0 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/shared/provider_ops_credential.rs apps/aether-gateway/src/handlers A 381 0 present unchanged_at_HEAD 1 -apps/aether-gateway/src/handlers/shared/request_utils.rs apps/aether-gateway/src/handlers M 82 3 present unchanged_at_HEAD 14 -apps/aether-gateway/src/handlers/shared/runtime_secret.rs apps/aether-gateway/src/handlers A 130 0 present unchanged_at_HEAD 2 -apps/aether-gateway/src/handlers/shared/system_config_values.rs apps/aether-gateway/src/handlers M 1226 0 present changed_after_hardening 9 -apps/aether-gateway/src/headers.rs apps/aether-gateway/src/headers.rs M 415 82 present unchanged_at_HEAD 8 -apps/aether-gateway/src/important_notification.rs apps/aether-gateway/src/important_notification.rs M 66 5 present changed_after_hardening 0 -apps/aether-gateway/src/internal_gateway_auth.rs apps/aether-gateway/src/internal_gateway_auth.rs A 234 0 present unchanged_at_HEAD 8 -apps/aether-gateway/src/lib.rs apps/aether-gateway/src/lib.rs M 78 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/local_auth_token.rs apps/aether-gateway/src/local_auth_token.rs A 457 0 present unchanged_at_HEAD 12 -apps/aether-gateway/src/main.rs apps/aether-gateway/src/main.rs M 1292 26 present changed_after_hardening 3 -apps/aether-gateway/src/maintenance/mod.rs apps/aether-gateway/src/maintenance M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime.rs apps/aether-gateway/src/maintenance M 7 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/account_self_check.rs apps/aether-gateway/src/maintenance M 101 49 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/cleanup_runs.rs apps/aether-gateway/src/maintenance M 125 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/fixed_provider_reconciliation.rs apps/aether-gateway/src/maintenance M 2 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/oauth_token_refresh.rs apps/aether-gateway/src/maintenance M 1 2 present changed_after_hardening 0 -apps/aether-gateway/src/maintenance/runtime/pool_quota_probe.rs apps/aether-gateway/src/maintenance M 42 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/provider_quota_alert.rs apps/aether-gateway/src/maintenance M 8 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/proxy_node_staleness.rs apps/aether-gateway/src/maintenance M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/proxy_upgrade_rollout.rs apps/aether-gateway/src/maintenance M 86 28 present unchanged_at_HEAD 12 -apps/aether-gateway/src/maintenance/runtime/runners.rs apps/aether-gateway/src/maintenance M 69 17 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/tests.rs apps/aether-gateway/src/maintenance M 156 14 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/usage_cleanup.rs apps/aether-gateway/src/maintenance M 43 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/maintenance/runtime/workers.rs apps/aether-gateway/src/maintenance M 5 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/management_token_auth.rs apps/aether-gateway/src/management_token_auth.rs A 255 0 present unchanged_at_HEAD 3 -apps/aether-gateway/src/middleware/mod.rs apps/aether-gateway/src/middleware M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/model_fetch/mod.rs apps/aether-gateway/src/model_fetch M 1 1 present changed_after_hardening 0 -apps/aether-gateway/src/model_fetch/runtime.rs apps/aether-gateway/src/model_fetch M 231 9 present changed_after_hardening 1 -apps/aether-gateway/src/oauth/http_executor.rs apps/aether-gateway/src/oauth M 703 63 present changed_after_hardening 13 -apps/aether-gateway/src/oauth/identity_repo.rs apps/aether-gateway/src/oauth M 175 68 present changed_after_hardening 2 -apps/aether-gateway/src/oauth/mod.rs apps/aether-gateway/src/oauth M 4 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/oauth/provider_repo.rs apps/aether-gateway/src/oauth M 0 18 present unchanged_at_HEAD 0 -apps/aether-gateway/src/oauth/proxy.rs apps/aether-gateway/src/oauth M 11 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/oauth/state_store.rs apps/aether-gateway/src/oauth M 277 8 present unchanged_at_HEAD 9 -apps/aether-gateway/src/orchestration/effects.rs apps/aether-gateway/src/orchestration M 34 17 present unchanged_at_HEAD 1 -apps/aether-gateway/src/orchestration/mod.rs apps/aether-gateway/src/orchestration M 31 1 present unchanged_at_HEAD 3 -apps/aether-gateway/src/orchestration/report_effects.rs apps/aether-gateway/src/orchestration M 56 16 present unchanged_at_HEAD 0 -apps/aether-gateway/src/plan_usage_policy.rs apps/aether-gateway/src/plan_usage_policy.rs A 1487 0 present unchanged_at_HEAD 17 -apps/aether-gateway/src/privacy/mod.rs apps/aether-gateway/src/privacy M 720 189 present unchanged_at_HEAD 52 -apps/aether-gateway/src/rate_limit.rs apps/aether-gateway/src/rate_limit.rs M 15 1 present unchanged_at_HEAD 5 -apps/aether-gateway/src/request_candidate_runtime.rs apps/aether-gateway/src/request_candidate_runtime.rs M 4 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/router.rs apps/aether-gateway/src/router.rs M 143 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/routing/mutations.rs apps/aether-gateway/src/routing M 72 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/routing/resolver.rs apps/aether-gateway/src/routing M 97 25 present unchanged_at_HEAD 1 -apps/aether-gateway/src/scheduler/candidate/tests/selection.rs apps/aether-gateway/src/scheduler M 31 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/server_chan_push.rs apps/aether-gateway/src/server_chan_push.rs M 436 47 present changed_after_hardening 4 -apps/aether-gateway/src/state/admin_types.rs apps/aether-gateway/src/state M 3 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/app.rs apps/aether-gateway/src/state M 74 5 present unchanged_at_HEAD 1 -apps/aether-gateway/src/state/bootstrap_admin.rs apps/aether-gateway/src/state M 20 1 present unchanged_at_HEAD 3 -apps/aether-gateway/src/state/catalog.rs apps/aether-gateway/src/state M 189 34 present changed_after_hardening 0 -apps/aether-gateway/src/state/catalog_credentials.rs apps/aether-gateway/src/state A 395 0 present changed_after_hardening 1 -apps/aether-gateway/src/state/catalog_proxy.rs apps/aether-gateway/src/state A 1860 0 present unchanged_at_HEAD 30 -apps/aether-gateway/src/state/core.rs apps/aether-gateway/src/state M 127 23 present changed_after_hardening 2 -apps/aether-gateway/src/state/integrations.rs apps/aether-gateway/src/state M 47 27 present changed_after_hardening 0 -apps/aether-gateway/src/state/mod.rs apps/aether-gateway/src/state M 9 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/oauth.rs apps/aether-gateway/src/state M 382 279 present changed_after_hardening 12 -apps/aether-gateway/src/state/proxy.rs apps/aether-gateway/src/state M 1558 39 present unchanged_at_HEAD 72 -apps/aether-gateway/src/state/runtime/api_key_exports.rs apps/aether-gateway/src/state M 338 0 present unchanged_at_HEAD 10 -apps/aether-gateway/src/state/runtime/auth/sessions.rs apps/aether-gateway/src/state M 125 6 present unchanged_at_HEAD 4 -apps/aether-gateway/src/state/runtime/auth/user_lifecycle.rs apps/aether-gateway/src/state M 465 11 present unchanged_at_HEAD 13 -apps/aether-gateway/src/state/runtime/auth/user_provisioning.rs apps/aether-gateway/src/state M 1190 98 present unchanged_at_HEAD 28 -apps/aether-gateway/src/state/runtime/billing/admin.rs apps/aether-gateway/src/state M 131 3 present unchanged_at_HEAD 2 -apps/aether-gateway/src/state/runtime/billing/finance_queries.rs apps/aether-gateway/src/state M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/runtime/billing/mod.rs apps/aether-gateway/src/state M 2 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/runtime/candidate_queries.rs apps/aether-gateway/src/state M 6 3 present unchanged_at_HEAD 3 -apps/aether-gateway/src/state/runtime/gemini_files.rs apps/aether-gateway/src/state M 80 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/runtime/monitoring.rs apps/aether-gateway/src/state M 42 3 present unchanged_at_HEAD 5 -apps/aether-gateway/src/state/runtime/payments.rs apps/aether-gateway/src/state M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/runtime/referrals.rs apps/aether-gateway/src/state M 77 5 present changed_after_hardening 0 -apps/aether-gateway/src/state/runtime/wallet/mutations.rs apps/aether-gateway/src/state M 68 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/runtime/wallet/reads.rs apps/aether-gateway/src/state M 34 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/state/runtime/wallet/refund_lifecycle.rs apps/aether-gateway/src/state M 291 26 present unchanged_at_HEAD 6 -apps/aether-gateway/src/state/testing.rs apps/aether-gateway/src/state M 168 42 present unchanged_at_HEAD 2 -apps/aether-gateway/src/state/types.rs apps/aether-gateway/src/state M 86 3 present unchanged_at_HEAD 4 -apps/aether-gateway/src/state/video.rs apps/aether-gateway/src/state M 124 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/task_runtime/mod.rs apps/aether-gateway/src/task_runtime M 26 47 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/control_execute.rs apps/aether-gateway/src/tests M 50 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/fallback.rs apps/aether-gateway/src/tests M 45 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs apps/aether-gateway/src/tests M 10 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs apps/aether-gateway/src/tests M 10 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/mod.rs apps/aether-gateway/src/tests M 84 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/stream/decision.rs apps/aether-gateway/src/tests M 9 13 present changed_after_hardening 0 -apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/stream_provider.rs apps/aether-gateway/src/tests M 20 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs apps/aether-gateway/src/tests M 30 10 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs apps/aether-gateway/src/tests M 3 8 present changed_after_hardening 0 -apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs apps/aether-gateway/src/tests M 51 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs apps/aether-gateway/src/tests M 10 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs apps/aether-gateway/src/tests M 11 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs apps/aether-gateway/src/tests M 11 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/cli.rs apps/aether-gateway/src/tests M 65 11 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs apps/aether-gateway/src/tests M 42 12 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs apps/aether-gateway/src/tests M 17 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/architecture/admin_provider.rs apps/aether-gateway/src/tests M 26 2 present changed_after_hardening 0 -apps/aether-gateway/src/tests/architecture/admin_shared.rs apps/aether-gateway/src/tests M 2 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/architecture/ai_serving.rs apps/aether-gateway/src/tests M 4 1 present changed_after_hardening 0 -apps/aether-gateway/src/tests/architecture/runtime_and_security.rs apps/aether-gateway/src/tests M 62 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/async_task.rs apps/aether-gateway/src/tests M 237 106 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/audit.rs apps/aether-gateway/src/tests M 28 21 present changed_after_hardening 0 -apps/aether-gateway/src/tests/concurrency.rs apps/aether-gateway/src/tests M 49 9 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/api_keys.rs apps/aether-gateway/src/tests M 98 16 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs apps/aether-gateway/src/tests M 128 142 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs apps/aether-gateway/src/tests M 57 79 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs apps/aether-gateway/src/tests M 6 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/health_access.rs apps/aether-gateway/src/tests M 108 27 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/ldap.rs apps/aether-gateway/src/tests M 66 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/models/external.rs apps/aether-gateway/src/tests M 17 7 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/models/global.rs apps/aether-gateway/src/tests M 33 11 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/monitoring.rs apps/aether-gateway/src/tests M 4 3 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/oauth.rs apps/aether-gateway/src/tests M 499 400 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/payments.rs apps/aether-gateway/src/tests M 14 17 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/pool.rs apps/aether-gateway/src/tests M 60 75 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/provider_ops.rs apps/aether-gateway/src/tests M 78 31 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/provider_query.rs apps/aether-gateway/src/tests M 124 138 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/providers.rs apps/aether-gateway/src/tests M 13 22 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/proxy_nodes.rs apps/aether-gateway/src/tests M 309 99 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/security.rs apps/aether-gateway/src/tests M 56 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/system.rs apps/aether-gateway/src/tests M 290 66 present changed_after_hardening 0 -apps/aether-gateway/src/tests/control/admin/system_import.rs apps/aether-gateway/src/tests M 1625 256 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/usage.rs apps/aether-gateway/src/tests M 7 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/users.rs apps/aether-gateway/src/tests M 327 21 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/video_tasks.rs apps/aether-gateway/src/tests M 53 58 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/admin/wallets.rs apps/aether-gateway/src/tests M 123 5 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/helpers.rs apps/aether-gateway/src/tests M 154 3 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/internal.rs apps/aether-gateway/src/tests M 833 112 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/mod.rs apps/aether-gateway/src/tests M 3 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/proxy/embeddings.rs apps/aether-gateway/src/tests M 7 7 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/proxy/local_denials.rs apps/aether-gateway/src/tests M 235 52 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/control/proxy/rerank.rs apps/aether-gateway/src/tests M 2 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/files/mod.rs apps/aether-gateway/src/tests M 151 31 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/files/stream.rs apps/aether-gateway/src/tests M 12 4 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/files/sync.rs apps/aether-gateway/src/tests M 36 76 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v22.json apps/aether-gateway/src/tests M 10 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/frontdoor/ai.rs apps/aether-gateway/src/tests M 334 10 present changed_after_hardening 0 -apps/aether-gateway/src/tests/frontdoor/internal.rs apps/aether-gateway/src/tests M 675 152 present changed_after_hardening 0 -apps/aether-gateway/src/tests/frontdoor/oauth.rs apps/aether-gateway/src/tests M 568 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/frontdoor/ops.rs apps/aether-gateway/src/tests M 1 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/frontdoor/public_support.rs apps/aether-gateway/src/tests M 1669 156 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/frontdoor/public_support/vscodex.rs apps/aether-gateway/src/tests M 5 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/mod.rs apps/aether-gateway/src/tests M 39 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/operational_auth.rs apps/aether-gateway/src/tests A 717 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/proxy.rs apps/aether-gateway/src/tests M 581 26 present changed_after_hardening 0 -apps/aether-gateway/src/tests/usage/local.rs apps/aether-gateway/src/tests M 37 137 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/data_read.rs apps/aether-gateway/src/tests M 174 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/gemini_sync_create.rs apps/aether-gateway/src/tests M 8 2 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/gemini_sync_task.rs apps/aether-gateway/src/tests M 91 106 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/mod.rs apps/aether-gateway/src/tests M 140 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/openai_sync_create.rs apps/aether-gateway/src/tests M 67 7 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/openai_sync_task.rs apps/aether-gateway/src/tests M 88 80 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/registry_poller.rs apps/aether-gateway/src/tests M 39 20 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tests/video/stream.rs apps/aether-gateway/src/tests M 96 85 present unchanged_at_HEAD 0 -apps/aether-gateway/src/tunnel/embedded/control_plane.rs apps/aether-gateway/src/tunnel M 456 72 present unchanged_at_HEAD 6 -apps/aether-gateway/src/tunnel/embedded/hub.rs apps/aether-gateway/src/tunnel M 494 76 present changed_after_hardening 8 -apps/aether-gateway/src/tunnel/embedded/local_relay.rs apps/aether-gateway/src/tunnel M 293 154 present changed_after_hardening 29 -apps/aether-gateway/src/tunnel/embedded/mod.rs apps/aether-gateway/src/tunnel M 2066 62 present changed_after_hardening 24 -apps/aether-gateway/src/tunnel/embedded/proxy_conn.rs apps/aether-gateway/src/tunnel M 325 14 present changed_after_hardening 1 -apps/aether-gateway/src/tunnel/mod.rs apps/aether-gateway/src/tunnel M 2336 207 present changed_after_hardening 15 -apps/aether-gateway/src/usage/http.rs apps/aether-gateway/src/usage M 6 1 present unchanged_at_HEAD 1 -apps/aether-gateway/src/usage/mod.rs apps/aether-gateway/src/usage M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/usage/reporting/context.rs apps/aether-gateway/src/usage M 348 5 present changed_after_hardening 15 -apps/aether-gateway/src/usage/reporting/mod.rs apps/aether-gateway/src/usage M 564 52 present changed_after_hardening 5 -apps/aether-gateway/src/video_tasks/mod.rs apps/aether-gateway/src/video_tasks M 12 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/video_tasks/service.rs apps/aether-gateway/src/video_tasks M 39 1 present unchanged_at_HEAD 0 -apps/aether-gateway/src/video_tasks/tests/fixtures.rs apps/aether-gateway/src/video_tasks M 1 0 present unchanged_at_HEAD 0 -apps/aether-gateway/src/video_tasks/tests/plans.rs apps/aether-gateway/src/video_tasks M 113 6 present unchanged_at_HEAD 0 -apps/aether-gateway/src/video_tasks/tests/projection.rs apps/aether-gateway/src/video_tasks M 37 8 present unchanged_at_HEAD 0 -apps/aether-gateway/src/video_tasks/tests/sync.rs apps/aether-gateway/src/video_tasks M 7 0 present unchanged_at_HEAD 0 -apps/aether-tunnel/Cargo.toml apps M 3 2 present changed_after_hardening 0 -apps/aether-tunnel/README.md apps M 22 14 present changed_after_hardening 0 -apps/aether-tunnel/install.ps1 apps M 409 28 present unchanged_at_HEAD 0 -apps/aether-tunnel/install.sh apps M 428 30 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/app.rs apps M 36 11 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/config.rs apps M 595 13 present changed_after_hardening 17 -apps/aether-tunnel/src/egress_proxy.rs apps M 107 15 present unchanged_at_HEAD 5 -apps/aether-tunnel/src/main.rs apps M 2 2 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/net.rs apps M 131 17 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/registration/client.rs apps M 117 18 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/safe_dns.rs apps D 0 66 absent unchanged_at_HEAD 0 -apps/aether-tunnel/src/setup/service.rs apps M 502 47 present changed_after_hardening 0 -apps/aether-tunnel/src/setup/tui.rs apps M 2 9 present unchanged_at_HEAD 1 -apps/aether-tunnel/src/setup/upgrade.rs apps M 967 105 present changed_after_hardening 8 -apps/aether-tunnel/src/state.rs apps M 2 0 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/target_filter.rs apps M 170 114 present unchanged_at_HEAD 0 -apps/aether-tunnel/src/tunnel/client.rs apps M 136 20 present changed_after_hardening 0 -apps/aether-tunnel/src/tunnel/dispatcher.rs apps M 223 15 present changed_after_hardening 2 -apps/aether-tunnel/src/tunnel/heartbeat.rs apps M 27 3 present changed_after_hardening 0 -apps/aether-tunnel/src/tunnel/mod.rs apps M 76 4 present changed_after_hardening 0 -apps/aether-tunnel/src/tunnel/stream_handler.rs apps M 995 225 present changed_after_hardening 12 -apps/aether-tunnel/src/upstream_client.rs apps M 248 134 present unchanged_at_HEAD 3 -crates/aether-admin/src/observability/monitoring.rs crates/aether-admin M 42 101 present changed_after_hardening 28 -crates/aether-admin/src/observability/usage.rs crates/aether-admin M 208 32 present unchanged_at_HEAD 1 -crates/aether-admin/src/provider/endpoints.rs crates/aether-admin M 37 44 present unchanged_at_HEAD 1 -crates/aether-admin/src/provider/mod.rs crates/aether-admin M 1 0 present unchanged_at_HEAD 1 -crates/aether-admin/src/provider/ops/actions.rs crates/aether-admin M 60 17 present unchanged_at_HEAD 2 -crates/aether-admin/src/provider/ops/verify.rs crates/aether-admin M 84 35 present unchanged_at_HEAD 2 -crates/aether-admin/src/provider/pool.rs crates/aether-admin M 158 12 present unchanged_at_HEAD 12 -crates/aether-admin/src/provider/quota.rs crates/aether-admin M 123 108 present changed_after_hardening 1 -crates/aether-admin/src/provider/redaction.rs crates/aether-admin A 2550 0 present changed_after_hardening 98 -crates/aether-admin/src/provider/state.rs crates/aether-admin M 28 1 present unchanged_at_HEAD 0 -crates/aether-admin/src/provider/status.rs crates/aether-admin M 26 0 present unchanged_at_HEAD 2 -crates/aether-admin/src/system.rs crates/aether-admin M 994 54 present changed_after_hardening 40 -crates/aether-ai/formats/src/formats/openai/image/mod.rs crates/aether-ai M 93 0 present changed_after_hardening 3 -crates/aether-ai/formats/src/formats/openai/image/request.rs crates/aether-ai M 565 33 present unchanged_at_HEAD 18 -crates/aether-ai/formats/src/formats/openai/image/stream.rs crates/aether-ai M 604 70 present unchanged_at_HEAD 8 -crates/aether-ai/formats/src/formats/shared/image_bridge.rs crates/aether-ai M 316 57 present unchanged_at_HEAD 5 -crates/aether-ai/formats/src/formats/shared/mod.rs crates/aether-ai M 35 0 present unchanged_at_HEAD 1 -crates/aether-ai/formats/src/formats/shared/routing.rs crates/aether-ai M 36 1 present unchanged_at_HEAD 11 -crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs crates/aether-ai M 79 2 present unchanged_at_HEAD 2 -crates/aether-ai/formats/src/formats/shared/sync_products.rs crates/aether-ai M 6 6 present changed_after_hardening 0 -crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs crates/aether-ai M 157 42 present unchanged_at_HEAD 7 -crates/aether-ai/formats/src/protocol/canonical.rs crates/aether-ai M 456 19 present unchanged_at_HEAD 0 -crates/aether-ai/formats/src/provider_compat/private_envelope.rs crates/aether-ai M 33 0 present unchanged_at_HEAD 0 -crates/aether-ai/serving/src/attempt_plan.rs crates/aether-ai M 56 4 present unchanged_at_HEAD 3 -crates/aether-ai/serving/src/candidate_preparation.rs crates/aether-ai M 15 1 present unchanged_at_HEAD 0 -crates/aether-ai/serving/src/dto.rs crates/aether-ai M 138 5 present unchanged_at_HEAD 3 -crates/aether-contracts/Cargo.toml crates/aether-contracts M 1 0 present unchanged_at_HEAD 0 -crates/aether-contracts/src/internal_gateway.rs crates/aether-contracts A 141 0 present unchanged_at_HEAD 3 -crates/aether-contracts/src/lib.rs crates/aether-contracts M 6 4 present unchanged_at_HEAD 1 -crates/aether-contracts/src/plan.rs crates/aether-contracts M 186 6 present unchanged_at_HEAD 12 -crates/aether-contracts/src/result.rs crates/aether-contracts M 93 2 present unchanged_at_HEAD 6 -crates/aether-contracts/src/tunnel.rs crates/aether-contracts M 500 21 present changed_after_hardening 10 -crates/aether-contracts/src/tunnel_security.rs crates/aether-contracts M 547 4 present changed_after_hardening 8 -crates/aether-crypto/Cargo.toml crates/aether-crypto M 1 0 present unchanged_at_HEAD 0 -crates/aether-crypto/src/lib.rs crates/aether-crypto M 2 0 present unchanged_at_HEAD 0 -crates/aether-crypto/src/python_fernet.rs crates/aether-crypto M 154 20 present unchanged_at_HEAD 5 -crates/aether-crypto/src/rsa_pkcs1_sha256.rs crates/aether-crypto A 257 0 present unchanged_at_HEAD 1 -crates/aether-data/adapters/mysql/migrations/20260403000000_baseline.sql crates/aether-data/adapters M 1 1 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260814000000_add_usage_cost_reservations.sql crates/aether-data/adapters A 35 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260815000000_add_usage_request_admissions.sql crates/aether-data/adapters A 22 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260816000000_add_usage_cost_reservation_user_foreign_key.sql crates/aether-data/adapters A 12 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260817000000_add_usage_request_admission_user_foreign_key.sql crates/aether-data/adapters A 10 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260821120000_enforce_payment_gateway_order_uniqueness.sql crates/aether-data/adapters A 55 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260821130000_add_user_security_version.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260822000000_purge_request_candidate_sensitive_diagnostics.sql crates/aether-data/adapters A 16 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260822010000_purge_usage_and_video_task_sensitive_diagnostics.sql crates/aether-data/adapters A 124 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260822020000_purge_background_task_sensitive_diagnostics.sql crates/aether-data/adapters A 43 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260827000000_purge_plaintext_stripe_client_secrets.sql crates/aether-data/adapters A 13 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260827010000_purge_legacy_payment_callback_payloads.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260827020000_purge_video_task_prompts.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260827030000_purge_identity_oauth_raw_userinfo.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260827040000_expand_proxy_password_ciphertext.sql crates/aether-data/adapters A 3 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260827050000_anonymize_deleted_user_history.sql crates/aether-data/adapters A 377 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260829000000_harden_deleted_user_history_anonymization.sql crates/aether-data/adapters A 451 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260831000000_enforce_ldap_config_singleton.sql crates/aether-data/adapters A 13 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260831010000_add_proxy_node_tunnel_generation.sql crates/aether-data/adapters A 9 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260831020000_enforce_proxy_node_endpoint_uniqueness.sql crates/aether-data/adapters A 10 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260831030000_add_usage_counter_delta_tunnel_generation.sql crates/aether-data/adapters A 2 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/migrations/20260903010000_reset_legacy_oauth_email_verification.sql crates/aether-data/adapters A 8 0 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/auth.rs crates/aether-data/adapters M 555 109 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/auth_modules.rs crates/aether-data/adapters M 275 84 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/background_tasks.rs crates/aether-data/adapters M 12 6 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/billing.rs crates/aether-data/adapters M 154 2 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/candidate_selection.rs crates/aether-data/adapters M 101 5 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/candidates.rs crates/aether-data/adapters M 148 110 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/gemini_file_mappings.rs crates/aether-data/adapters M 202 2 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/management_tokens.rs crates/aether-data/adapters M 399 94 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/oauth_providers.rs crates/aether-data/adapters M 111 13 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/pool.rs crates/aether-data/adapters M 58 5 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/provider_catalog.rs crates/aether-data/adapters M 190 60 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/proxy_nodes.rs crates/aether-data/adapters M 1197 294 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/settlement.rs crates/aether-data/adapters M 752 14 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/usage.rs crates/aether-data/adapters M 49 49 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/usage/cleanup.rs crates/aether-data/adapters M 81 393 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/usage/counters.rs crates/aether-data/adapters M 69 12 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/usage/http_capture.rs crates/aether-data/adapters M 80 151 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/usage/tests.rs crates/aether-data/adapters M 146 32 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/users.rs crates/aether-data/adapters M 1342 125 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/video_tasks.rs crates/aether-data/adapters M 261 49 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/wallet.rs crates/aether-data/adapters M 2257 362 absent changed_after_hardening 0 -crates/aether-data/adapters/mysql/src/wallet/tests.rs crates/aether-data/adapters M 61 1 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260403000000_baseline.sql crates/aether-data/adapters M 1 1 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260814000000_add_usage_cost_reservations.sql crates/aether-data/adapters A 43 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260815000000_add_usage_request_admissions.sql crates/aether-data/adapters A 25 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260816000000_add_usage_policy_user_foreign_keys.sql crates/aether-data/adapters A 25 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260821120000_enforce_payment_gateway_order_uniqueness.sql crates/aether-data/adapters A 19 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260821130000_add_user_security_version.sql crates/aether-data/adapters A 5 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260822000000_purge_request_candidate_sensitive_diagnostics.sql crates/aether-data/adapters A 16 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260822010000_purge_usage_and_video_task_sensitive_diagnostics.sql crates/aether-data/adapters A 98 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260822020000_purge_background_task_sensitive_diagnostics.sql crates/aether-data/adapters A 43 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260827000000_purge_plaintext_stripe_client_secrets.sql crates/aether-data/adapters A 11 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260827010000_purge_legacy_payment_callback_payloads.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260827020000_purge_video_task_prompts.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260827030000_purge_identity_oauth_raw_userinfo.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260827040000_expand_proxy_password_ciphertext.sql crates/aether-data/adapters A 3 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260827050000_anonymize_deleted_user_history.sql crates/aether-data/adapters A 362 0 present changed_after_hardening 38 -crates/aether-data/adapters/postgres/migrations/20260829000000_harden_deleted_user_history_anonymization.sql crates/aether-data/adapters A 444 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260831000000_enforce_ldap_config_singleton.sql crates/aether-data/adapters A 34 0 present changed_after_hardening 0 -crates/aether-data/adapters/postgres/migrations/20260831010000_add_proxy_node_tunnel_generation.sql crates/aether-data/adapters A 20 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260831030000_add_usage_counter_delta_tunnel_generation.sql crates/aether-data/adapters A 2 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260901000000_expand_gemini_file_mapping_metadata.sql crates/aether-data/adapters A 6 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/migrations/20260903010000_reset_legacy_oauth_email_verification.sql crates/aether-data/adapters A 10 0 absent changed_after_hardening 0 -crates/aether-data/adapters/postgres/src/auth.rs crates/aether-data/adapters M 588 96 present unchanged_at_HEAD 27 -crates/aether-data/adapters/postgres/src/auth_modules.rs crates/aether-data/adapters M 241 74 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/background_tasks.rs crates/aether-data/adapters M 12 6 present unchanged_at_HEAD 4 -crates/aether-data/adapters/postgres/src/billing.rs crates/aether-data/adapters M 117 2 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/candidate_selection.rs crates/aether-data/adapters M 101 5 present unchanged_at_HEAD 1 -crates/aether-data/adapters/postgres/src/candidates.rs crates/aether-data/adapters M 254 236 present changed_after_hardening 20 -crates/aether-data/adapters/postgres/src/gemini_file_mappings.rs crates/aether-data/adapters M 173 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/management_tokens.rs crates/aether-data/adapters M 364 109 present unchanged_at_HEAD 7 -crates/aether-data/adapters/postgres/src/migrations.rs crates/aether-data/adapters M 33 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/oauth_providers.rs crates/aether-data/adapters M 111 9 present unchanged_at_HEAD 3 -crates/aether-data/adapters/postgres/src/pool.rs crates/aether-data/adapters M 48 7 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/provider_catalog.rs crates/aether-data/adapters M 232 78 present unchanged_at_HEAD 2 -crates/aether-data/adapters/postgres/src/proxy_nodes.rs crates/aether-data/adapters M 550 134 present changed_after_hardening 20 -crates/aether-data/adapters/postgres/src/settlement.rs crates/aether-data/adapters M 677 7 present unchanged_at_HEAD 6 -crates/aether-data/adapters/postgres/src/usage/cleanup.rs crates/aether-data/adapters M 141 462 present unchanged_at_HEAD 1 -crates/aether-data/adapters/postgres/src/usage/mod.rs crates/aether-data/adapters M 296 212 present unchanged_at_HEAD 39 -crates/aether-data/adapters/postgres/src/usage/queries/find_by_id_sql.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/usage/queries/find_by_request_id_sql.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/usage/queries/upsert_first_byte_sql.sql crates/aether-data/adapters M 4 0 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/usage/tests.rs crates/aether-data/adapters M 233 28 present unchanged_at_HEAD 0 -crates/aether-data/adapters/postgres/src/users.rs crates/aether-data/adapters M 1503 61 present changed_after_hardening 67 -crates/aether-data/adapters/postgres/src/video_tasks.rs crates/aether-data/adapters M 157 7 present unchanged_at_HEAD 2 -crates/aether-data/adapters/postgres/src/wallet.rs crates/aether-data/adapters M 2525 391 present unchanged_at_HEAD 26 -crates/aether-data/adapters/sqlite/migrations/20260814000000_add_usage_cost_reservations.sql crates/aether-data/adapters A 34 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260815000000_add_usage_request_admissions.sql crates/aether-data/adapters A 21 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260816000000_add_usage_policy_user_foreign_keys.sql crates/aether-data/adapters A 117 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260821120000_enforce_payment_gateway_order_uniqueness.sql crates/aether-data/adapters A 19 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260821130000_add_user_security_version.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260822000000_purge_request_candidate_sensitive_diagnostics.sql crates/aether-data/adapters A 16 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260822010000_purge_usage_and_video_task_sensitive_diagnostics.sql crates/aether-data/adapters A 121 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260822020000_purge_background_task_sensitive_diagnostics.sql crates/aether-data/adapters A 43 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260827000000_purge_plaintext_stripe_client_secrets.sql crates/aether-data/adapters A 13 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260827010000_purge_legacy_payment_callback_payloads.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260827020000_purge_video_task_prompts.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260827030000_purge_identity_oauth_raw_userinfo.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260827040000_expand_proxy_password_ciphertext.sql crates/aether-data/adapters A 2 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260827050000_anonymize_deleted_user_history.sql crates/aether-data/adapters A 444 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260829000000_harden_deleted_user_history_anonymization.sql crates/aether-data/adapters A 449 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260831000000_enforce_ldap_config_singleton.sql crates/aether-data/adapters A 11 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260831010000_add_proxy_node_tunnel_generation.sql crates/aether-data/adapters A 20 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260831020000_enforce_proxy_node_endpoint_uniqueness.sql crates/aether-data/adapters A 9 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260831030000_add_usage_counter_delta_tunnel_generation.sql crates/aether-data/adapters A 2 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/migrations/20260903010000_reset_legacy_oauth_email_verification.sql crates/aether-data/adapters A 8 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/auth.rs crates/aether-data/adapters M 957 108 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/auth_modules.rs crates/aether-data/adapters M 385 93 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/background_tasks.rs crates/aether-data/adapters M 26 9 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/billing.rs crates/aether-data/adapters M 215 3 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/candidate_selection.rs crates/aether-data/adapters M 103 7 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/candidates.rs crates/aether-data/adapters M 204 118 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/gemini_file_mappings.rs crates/aether-data/adapters M 236 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/management_tokens.rs crates/aether-data/adapters M 616 97 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/migrations.rs crates/aether-data/adapters M 127 0 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/oauth_providers.rs crates/aether-data/adapters M 188 26 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/provider_catalog.rs crates/aether-data/adapters M 391 72 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/proxy_nodes.rs crates/aether-data/adapters M 1487 308 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/settlement.rs crates/aether-data/adapters M 849 14 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/usage.rs crates/aether-data/adapters M 71 61 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/usage/cleanup.rs crates/aether-data/adapters M 73 374 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/usage/counters.rs crates/aether-data/adapters M 333 13 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/usage/http_capture.rs crates/aether-data/adapters M 76 147 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/usage/tests.rs crates/aether-data/adapters M 355 96 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/users.rs crates/aether-data/adapters M 2946 198 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/video_tasks.rs crates/aether-data/adapters M 263 9 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/wallet.rs crates/aether-data/adapters M 2331 389 absent changed_after_hardening 0 -crates/aether-data/adapters/sqlite/src/wallet/tests.rs crates/aether-data/adapters M 4336 363 absent changed_after_hardening 0 -crates/aether-data/contracts/Cargo.toml crates/aether-data/contracts M 6 0 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/auth.rs crates/aether-data/contracts M 339 13 present unchanged_at_HEAD 13 -crates/aether-data/contracts/src/repository/auth_modules.rs crates/aether-data/contracts M 148 5 present changed_after_hardening 14 -crates/aether-data/contracts/src/repository/background_tasks/types.rs crates/aether-data/contracts M 421 2 present unchanged_at_HEAD 61 -crates/aether-data/contracts/src/repository/billing/mod.rs crates/aether-data/contracts M 20 2 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/billing/replacement.rs crates/aether-data/contracts A 200 0 present unchanged_at_HEAD 2 -crates/aether-data/contracts/src/repository/billing/types.rs crates/aether-data/contracts M 200 2 present unchanged_at_HEAD 14 -crates/aether-data/contracts/src/repository/billing/usage_policy.rs crates/aether-data/contracts A 1059 0 present unchanged_at_HEAD 4 -crates/aether-data/contracts/src/repository/candidates/mod.rs crates/aether-data/contracts M 8 5 present changed_after_hardening 3 -crates/aether-data/contracts/src/repository/candidates/types.rs crates/aether-data/contracts M 1655 20 present changed_after_hardening 213 -crates/aether-data/contracts/src/repository/gemini_file_mappings.rs crates/aether-data/contracts M 145 0 present unchanged_at_HEAD 1 -crates/aether-data/contracts/src/repository/management_tokens.rs crates/aether-data/contracts M 434 2 present unchanged_at_HEAD 33 -crates/aether-data/contracts/src/repository/oauth_providers.rs crates/aether-data/contracts M 536 5 present changed_after_hardening 16 -crates/aether-data/contracts/src/repository/provider_catalog/mod.rs crates/aether-data/contracts M 4 3 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/provider_catalog/types.rs crates/aether-data/contracts M 691 24 present unchanged_at_HEAD 61 -crates/aether-data/contracts/src/repository/proxy_nodes.rs crates/aether-data/contracts M 486 8 present unchanged_at_HEAD 32 -crates/aether-data/contracts/src/repository/settlement/mod.rs crates/aether-data/contracts M 7 2 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/settlement/types.rs crates/aether-data/contracts M 504 1 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/usage/compression.rs crates/aether-data/contracts A 58 0 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/usage/metadata_policy.rs crates/aether-data/contracts A 1541 0 present unchanged_at_HEAD 86 -crates/aether-data/contracts/src/repository/usage/mod.rs crates/aether-data/contracts M 12 7 present unchanged_at_HEAD 0 -crates/aether-data/contracts/src/repository/usage/policy.rs crates/aether-data/contracts M 642 0 present unchanged_at_HEAD 83 -crates/aether-data/contracts/src/repository/usage/types.rs crates/aether-data/contracts M 129 32 present unchanged_at_HEAD 6 -crates/aether-data/contracts/src/repository/users.rs crates/aether-data/contracts M 659 15 present changed_after_hardening 16 -crates/aether-data/contracts/src/repository/video_tasks/types.rs crates/aether-data/contracts M 384 4 present unchanged_at_HEAD 36 -crates/aether-data/contracts/src/repository/wallet/snapshot.rs crates/aether-data/contracts M 71 2 present unchanged_at_HEAD 8 -crates/aether-data/contracts/src/repository/wallet/types.rs crates/aether-data/contracts M 2900 26 present unchanged_at_HEAD 76 -crates/aether-data/runtime/schema/bootstrap/postgres/001_types_and_tables.sql crates/aether-data/runtime M 9 4 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/bootstrap/postgres/003_constraints.sql crates/aether-data/runtime M 30 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/bootstrap/postgres/004_indexes.sql crates/aether-data/runtime M 8 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/bootstrap/postgres/005_foreign_keys.sql crates/aether-data/runtime M 0 60 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/bootstrap/postgres/100_usage_capture.sql crates/aether-data/runtime M 78 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/drivers/mysql/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 1 1 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/drivers/postgres/baseline/001_types_and_tables.sql crates/aether-data/runtime M 1 1 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/generated/mysql/baseline/001_identity.sql crates/aether-data/runtime M 2 0 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/mysql/baseline/003_auth_config.sql crates/aether-data/runtime M 3 1 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/mysql/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 4 2 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/mysql/baseline/005_wallet_billing.sql crates/aether-data/runtime M 2 5 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/mysql/baseline/006_usage.sql crates/aether-data/runtime M 37 0 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/postgres/baseline/001_identity.sql crates/aether-data/runtime M 2 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/generated/postgres/baseline/003_auth_config.sql crates/aether-data/runtime M 2 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/generated/postgres/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 3 1 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/generated/postgres/baseline/005_wallet_billing.sql crates/aether-data/runtime M 1 4 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/generated/postgres/baseline/006_usage.sql crates/aether-data/runtime M 39 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/generated/sqlite/baseline/001_identity.sql crates/aether-data/runtime M 2 0 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/sqlite/baseline/003_auth_config.sql crates/aether-data/runtime M 3 1 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/sqlite/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 3 1 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/sqlite/baseline/005_wallet_billing.sql crates/aether-data/runtime M 1 4 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/generated/sqlite/baseline/006_usage.sql crates/aether-data/runtime M 35 0 absent changed_after_hardening 0 -crates/aether-data/runtime/schema/logical/001_identity.toml crates/aether-data/runtime M 10 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/logical/003_auth_config.toml crates/aether-data/runtime M 9 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/logical/004_proxy_nodes.toml crates/aether-data/runtime M 9 1 present unchanged_at_HEAD 0 -crates/aether-data/runtime/schema/logical/005_wallet_billing.toml crates/aether-data/runtime M 8 28 present changed_after_hardening 0 -crates/aether-data/runtime/schema/logical/006_usage.toml crates/aether-data/runtime M 145 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/src/backend/maintenance.rs crates/aether-data/runtime M 86 0 present changed_after_hardening 21 -crates/aether-data/runtime/src/backend/mod.rs crates/aether-data/runtime M 8 3 present changed_after_hardening 1 -crates/aether-data/runtime/src/backend/referrals.rs crates/aether-data/runtime M 3699 349 present changed_after_hardening 23 -crates/aether-data/runtime/src/backend/sqlite.rs crates/aether-data/runtime M 51 2 absent changed_after_hardening 0 -crates/aether-data/runtime/src/backend/system.rs crates/aether-data/runtime M 4 3 present changed_after_hardening 0 -crates/aether-data/runtime/src/backend/system/mysql.rs crates/aether-data/runtime M 34 1 absent changed_after_hardening 0 -crates/aether-data/runtime/src/backend/system/postgres.rs crates/aether-data/runtime M 30 1 present unchanged_at_HEAD 0 -crates/aether-data/runtime/src/backend/system/sqlite.rs crates/aether-data/runtime M 34 1 absent changed_after_hardening 0 -crates/aether-data/runtime/src/lifecycle/bootstrap/postgres.rs crates/aether-data/runtime M 4 1 present changed_after_hardening 0 -crates/aether-data/runtime/src/lifecycle/export.rs crates/aether-data/runtime M 582 2 present changed_after_hardening 37 -crates/aether-data/runtime/src/lifecycle/export/mysql.rs crates/aether-data/runtime M 207 1 absent changed_after_hardening 0 -crates/aether-data/runtime/src/lifecycle/export/postgres.rs crates/aether-data/runtime M 215 2 present changed_after_hardening 2 -crates/aether-data/runtime/src/lifecycle/export/sqlite.rs crates/aether-data/runtime M 207 1 absent changed_after_hardening 0 -crates/aether-data/runtime/src/lifecycle/export/tests.rs crates/aether-data/runtime M 886 24 present changed_after_hardening 0 -crates/aether-data/runtime/src/lifecycle/migrate/tests.rs crates/aether-data/runtime M 754 8 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/auth/memory.rs crates/aether-data/runtime M 1235 105 present changed_after_hardening 106 -crates/aether-data/runtime/src/repository/auth/mod.rs crates/aether-data/runtime M 2 2 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/auth_modules/memory.rs crates/aether-data/runtime M 270 24 present unchanged_at_HEAD 7 -crates/aether-data/runtime/src/repository/auth_modules/mod.rs crates/aether-data/runtime M 2 2 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/background_tasks/memory.rs crates/aether-data/runtime M 6 3 present unchanged_at_HEAD 3 -crates/aether-data/runtime/src/repository/billing/memory.rs crates/aether-data/runtime M 178 3 present unchanged_at_HEAD 5 -crates/aether-data/runtime/src/repository/candidates/memory.rs crates/aether-data/runtime M 208 55 present changed_after_hardening 29 -crates/aether-data/runtime/src/repository/gemini_file_mappings/memory.rs crates/aether-data/runtime M 209 0 present unchanged_at_HEAD 8 -crates/aether-data/runtime/src/repository/management_tokens/memory.rs crates/aether-data/runtime M 344 103 present unchanged_at_HEAD 16 -crates/aether-data/runtime/src/repository/management_tokens/mod.rs crates/aether-data/runtime M 4 4 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/oauth_providers/memory.rs crates/aether-data/runtime M 150 23 present unchanged_at_HEAD 3 -crates/aether-data/runtime/src/repository/oauth_providers/mod.rs crates/aether-data/runtime M 4 2 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/provider_catalog/memory.rs crates/aether-data/runtime M 123 53 present unchanged_at_HEAD 10 -crates/aether-data/runtime/src/repository/provider_catalog/mod.rs crates/aether-data/runtime M 4 3 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/provider_oauth.rs crates/aether-data/runtime M 49 7 present unchanged_at_HEAD 0 -crates/aether-data/runtime/src/repository/proxy_nodes/memory.rs crates/aether-data/runtime M 366 29 present unchanged_at_HEAD 51 -crates/aether-data/runtime/src/repository/settlement/memory.rs crates/aether-data/runtime M 960 20 present unchanged_at_HEAD 12 -crates/aether-data/runtime/src/repository/system.rs crates/aether-data/runtime M 1 0 present unchanged_at_HEAD 0 -crates/aether-data/runtime/src/repository/usage/memory.rs crates/aether-data/runtime M 182 260 present unchanged_at_HEAD 38 -crates/aether-data/runtime/src/repository/usage/memory/tests.rs crates/aether-data/runtime M 76 6 present unchanged_at_HEAD 0 -crates/aether-data/runtime/src/repository/usage/mod.rs crates/aether-data/runtime M 29 27 present changed_after_hardening 2 -crates/aether-data/runtime/src/repository/users/memory.rs crates/aether-data/runtime M 2261 384 present unchanged_at_HEAD 58 -crates/aether-data/runtime/src/repository/users/mod.rs crates/aether-data/runtime M 6 2 present changed_after_hardening 0 -crates/aether-data/runtime/src/repository/video_tasks/memory.rs crates/aether-data/runtime M 265 46 present unchanged_at_HEAD 10 -crates/aether-data/runtime/src/repository/wallet/memory.rs crates/aether-data/runtime M 2448 175 present changed_after_hardening 78 -crates/aether-data/runtime/src/repository/wallet/mod.rs crates/aether-data/runtime M 30 14 present changed_after_hardening 0 -crates/aether-data/schema/src/lib.rs crates/aether-data/schema M 24 0 present changed_after_hardening 2 -crates/aether-gateway/frontdoor/src/body.rs crates/aether-gateway M 289 36 present unchanged_at_HEAD 5 -crates/aether-gateway/frontdoor/src/lib.rs crates/aether-gateway M 1 3 present unchanged_at_HEAD 0 -crates/aether-gateway/frontdoor/src/middleware/cf_headers.rs crates/aether-gateway M 2 17 present unchanged_at_HEAD 0 -crates/aether-gateway/tunnel/src/embedded/protocol.rs crates/aether-gateway M 9 8 present unchanged_at_HEAD 0 -crates/aether-gateway/tunnel/src/hub.rs crates/aether-gateway M 24 1 present unchanged_at_HEAD 0 -crates/aether-gateway/tunnel/src/relay.rs crates/aether-gateway M 4 0 present unchanged_at_HEAD 0 -crates/aether-http/Cargo.toml crates/aether-http M 2 0 present unchanged_at_HEAD 0 -crates/aether-http/src/client.rs crates/aether-http M 3 1 present unchanged_at_HEAD 0 -crates/aether-http/src/dns.rs crates/aether-http A 91 0 present changed_after_hardening 0 -crates/aether-http/src/header_security.rs crates/aether-http A 184 0 present changed_after_hardening 0 -crates/aether-http/src/lib.rs crates/aether-http M 9 0 present changed_after_hardening 0 -crates/aether-http/src/response_body.rs crates/aether-http A 146 0 present unchanged_at_HEAD 0 -crates/aether-model-fetch/Cargo.toml crates/aether-model-fetch M 3 2 present unchanged_at_HEAD 0 -crates/aether-model-fetch/src/logic.rs crates/aether-model-fetch M 41 2 present changed_after_hardening 0 -crates/aether-model-fetch/src/strategy.rs crates/aether-model-fetch M 632 75 present changed_after_hardening 41 -crates/aether-model-fetch/src/transport.rs crates/aether-model-fetch M 22 5 present changed_after_hardening 0 -crates/aether-oauth/src/core/error.rs crates/aether-oauth M 371 10 present unchanged_at_HEAD 38 -crates/aether-oauth/src/core/flow.rs crates/aether-oauth M 148 5 present unchanged_at_HEAD 17 -crates/aether-oauth/src/core/mod.rs crates/aether-oauth M 1 1 present unchanged_at_HEAD 1 -crates/aether-oauth/src/core/token.rs crates/aether-oauth M 37 1 present unchanged_at_HEAD 5 -crates/aether-oauth/src/identity/adapter.rs crates/aether-oauth M 247 10 present unchanged_at_HEAD 26 -crates/aether-oauth/src/identity/providers/custom_oidc.rs crates/aether-oauth M 148 4 present unchanged_at_HEAD 11 -crates/aether-oauth/src/identity/providers/linuxdo.rs crates/aether-oauth M 48 1 present unchanged_at_HEAD 6 -crates/aether-oauth/src/lib.rs crates/aether-oauth M 2 2 present unchanged_at_HEAD 1 -crates/aether-oauth/src/network/context.rs crates/aether-oauth M 34 1 present unchanged_at_HEAD 0 -crates/aether-oauth/src/network/executor.rs crates/aether-oauth M 82 7 present unchanged_at_HEAD 7 -crates/aether-oauth/src/provider/account.rs crates/aether-oauth M 195 6 present unchanged_at_HEAD 31 -crates/aether-oauth/src/provider/providers/generic.rs crates/aether-oauth M 276 24 present changed_after_hardening 18 -crates/aether-oauth/src/provider/providers/kiro.rs crates/aether-oauth M 219 13 present unchanged_at_HEAD 36 -crates/aether-oauth/src/provider/providers/mod.rs crates/aether-oauth M 5 3 present changed_after_hardening 0 -crates/aether-provider/pool/Cargo.toml crates/aether-provider M 1 0 present unchanged_at_HEAD 0 -crates/aether-provider/pool/src/lib.rs crates/aether-provider M 28 1 present unchanged_at_HEAD 2 -crates/aether-provider/pool/src/providers/antigravity.rs crates/aether-provider M 0 2 present unchanged_at_HEAD 0 -crates/aether-provider/pool/src/providers/chatgpt_web.rs crates/aether-provider M 0 1 present unchanged_at_HEAD 0 -crates/aether-provider/pool/src/providers/codex.rs crates/aether-provider M 0 3 present unchanged_at_HEAD 0 -crates/aether-provider/pool/src/providers/gemini_cli.rs crates/aether-provider M 0 1 present unchanged_at_HEAD 0 -crates/aether-provider/pool/src/providers/kiro.rs crates/aether-provider M 36 8 present unchanged_at_HEAD 4 -crates/aether-provider/pool/src/providers/windsurf.rs crates/aether-provider M 0 1 present unchanged_at_HEAD 0 -crates/aether-provider/pool/src/quota_refresh.rs crates/aether-provider M 29 2 present unchanged_at_HEAD 2 -crates/aether-provider/transport/Cargo.toml crates/aether-provider M 2 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/agent_identity.rs crates/aether-provider M 74 8 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/auth.rs crates/aether-provider M 107 11 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/diagnostics.rs crates/aether-provider M 140 21 present unchanged_at_HEAD 2 -crates/aether-provider/transport/src/gemini_cli/auth.rs crates/aether-provider M 12 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/gemini_cli/request.rs crates/aether-provider M 19 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/gemini_files/mod.rs crates/aether-provider M 66 2 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/generic_oauth/mod.rs crates/aether-provider M 37 2 present changed_after_hardening 0 -crates/aether-provider/transport/src/grok.rs crates/aether-provider M 41 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/headers.rs crates/aether-provider M 146 2 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/kiro/auth.rs crates/aether-provider M 60 2 present unchanged_at_HEAD 6 -crates/aether-provider/transport/src/kiro/credentials.rs crates/aether-provider M 1 0 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/kiro/headers.rs crates/aether-provider M 30 4 present unchanged_at_HEAD 12 -crates/aether-provider/transport/src/kiro/mod.rs crates/aether-provider M 4 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/kiro/request.rs crates/aether-provider M 42 2 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/kiro/url.rs crates/aether-provider M 25 3 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/network.rs crates/aether-provider M 83 2 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/oauth_refresh/mod.rs crates/aether-provider M 288 26 present unchanged_at_HEAD 15 -crates/aether-provider/transport/src/openai_image/mod.rs crates/aether-provider M 41 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/provider_types.rs crates/aether-provider M 0 7 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/request_url/mod.rs crates/aether-provider M 142 13 present unchanged_at_HEAD 12 -crates/aether-provider/transport/src/same_format_provider/mod.rs crates/aether-provider M 120 4 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/snapshot.rs crates/aether-provider M 196 14 present unchanged_at_HEAD 6 -crates/aether-provider/transport/src/snapshot_mapping.rs crates/aether-provider M 79 25 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/standard/mod.rs crates/aether-provider M 102 3 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/url.rs crates/aether-provider M 101 7 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/vertex/auth.rs crates/aether-provider M 220 49 present unchanged_at_HEAD 9 -crates/aether-provider/transport/src/vertex/context.rs crates/aether-provider M 72 4 present unchanged_at_HEAD 3 -crates/aether-provider/transport/src/vertex/mod.rs crates/aether-provider M 3 2 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/vertex/url.rs crates/aether-provider M 90 6 present unchanged_at_HEAD 2 -crates/aether-provider/transport/src/video/mod.rs crates/aether-provider M 39 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/windsurf.rs crates/aether-provider M 6 1 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/windsurf/cascade.rs crates/aether-provider M 74 4 present unchanged_at_HEAD 0 -crates/aether-provider/transport/src/windsurf/proto.rs crates/aether-provider M 26 3 present unchanged_at_HEAD 0 -crates/aether-runtime/base/Cargo.toml crates/aether-runtime M 1 0 present unchanged_at_HEAD 0 -crates/aether-runtime/base/src/admission.rs crates/aether-runtime M 53 19 present unchanged_at_HEAD 0 -crates/aether-runtime/base/src/tracing.rs crates/aether-runtime M 102 7 present unchanged_at_HEAD 0 -crates/aether-runtime/state/src/lib.rs crates/aether-runtime M 967 3 present changed_after_hardening 2 -crates/aether-runtime/state/src/memory.rs crates/aether-runtime M 426 0 present unchanged_at_HEAD 11 -crates/aether-runtime/state/src/redis/client.rs crates/aether-runtime M 54 1 present unchanged_at_HEAD 3 -crates/aether-runtime/state/src/redis/runtime.rs crates/aether-runtime M 175 0 present unchanged_at_HEAD 0 -crates/aether-testing/integration/src/bin/capacity_curve_baseline.rs crates/aether-testing M 7 8 present unchanged_at_HEAD 0 -crates/aether-testing/integration/src/bin/failure_recovery_baseline.rs crates/aether-testing M 5 8 present unchanged_at_HEAD 0 -crates/aether-testing/integration/src/bin/gateway_pressure_seed.rs crates/aether-testing M 152 15 present unchanged_at_HEAD 3 -crates/aether-testing/integration/src/bin/gateway_tunnel_stream_baseline.rs crates/aether-testing M 5 6 present unchanged_at_HEAD 0 -crates/aether-testing/integration/src/bin/llm_stream_stability_baseline.rs crates/aether-testing M 4 5 present unchanged_at_HEAD 0 -crates/aether-testing/integration/src/bin/multi_instance_admission_baseline.rs crates/aether-testing M 7 11 present unchanged_at_HEAD 0 -crates/aether-testing/integration/src/bin/multi_instance_owner_relay_baseline.rs crates/aether-testing M 99 6 present unchanged_at_HEAD 2 -crates/aether-testing/integration/src/bin/usage_aux_counter_hotspot_baseline.rs crates/aether-testing M 7 2 present unchanged_at_HEAD 0 -crates/aether-testing/integration/tests/responses_websocket_e2e.rs crates/aether-testing M 194 1 present changed_after_hardening 0 -crates/aether-testing/testkit/Cargo.toml crates/aether-testing M 2 0 present unchanged_at_HEAD 0 -crates/aether-testing/testkit/src/lib.rs crates/aether-testing M 4 1 present unchanged_at_HEAD 0 -crates/aether-testing/testkit/src/tunnel.rs crates/aether-testing M 28 0 present unchanged_at_HEAD 0 -crates/aether-usage/runtime/src/body_capture.rs crates/aether-usage M 216 6 present changed_after_hardening 15 -crates/aether-usage/runtime/src/lib.rs crates/aether-usage M 10 7 present changed_after_hardening 0 -crates/aether-usage/runtime/src/record.rs crates/aether-usage M 2 1 present unchanged_at_HEAD 0 -crates/aether-usage/runtime/src/report.rs crates/aether-usage M 129 9 present changed_after_hardening 5 -crates/aether-usage/runtime/src/request_metadata.rs crates/aether-usage M 35 97 present changed_after_hardening 7 -crates/aether-usage/runtime/src/runtime.rs crates/aether-usage M 19 7 present unchanged_at_HEAD 1 -crates/aether-usage/runtime/src/settlement.rs crates/aether-usage M 388 17 present unchanged_at_HEAD 1 -crates/aether-usage/runtime/src/worker.rs crates/aether-usage M 51 3 present unchanged_at_HEAD 0 -crates/aether-usage/runtime/src/write.rs crates/aether-usage M 71 107 present changed_after_hardening 28 -crates/aether-video-tasks-core/Cargo.toml crates/aether-video-tasks-core M 1 0 present unchanged_at_HEAD 0 -crates/aether-video-tasks-core/src/gemini.rs crates/aether-video-tasks-core M 99 32 present unchanged_at_HEAD 13 -crates/aether-video-tasks-core/src/lib.rs crates/aether-video-tasks-core M 4 1 present unchanged_at_HEAD 0 -crates/aether-video-tasks-core/src/openai.rs crates/aether-video-tasks-core M 101 38 present unchanged_at_HEAD 16 -crates/aether-video-tasks-core/src/read_side.rs crates/aether-video-tasks-core M 51 4 present unchanged_at_HEAD 1 -crates/aether-video-tasks-core/src/service.rs crates/aether-video-tasks-core M 186 37 present unchanged_at_HEAD 0 -crates/aether-video-tasks-core/src/snapshot.rs crates/aether-video-tasks-core M 70 2 present unchanged_at_HEAD 9 -crates/aether-video-tasks-core/src/store_backend.rs crates/aether-video-tasks-core M 518 24 present changed_after_hardening 11 -crates/aether-video-tasks-core/src/store_registry.rs crates/aether-video-tasks-core M 10 1 present unchanged_at_HEAD 4 -crates/aether-video-tasks-core/src/types.rs crates/aether-video-tasks-core M 70 2 present unchanged_at_HEAD 10 -deploy.sh root-config M 33 3 present unchanged_at_HEAD 0 -docker-compose.single-node.yml root-config M 8 1 present changed_after_hardening 0 -docker-compose.yml root-config M 21 11 present changed_after_hardening 0 -frontend/package-lock.json frontend/package-lock.json M 210 147 present changed_after_hardening 0 -frontend/package.json frontend/package.json M 6 6 present changed_after_hardening 0 -frontend/src/App.vue frontend/src M 25 32 present unchanged_at_HEAD 0 -frontend/src/api/__tests__/auth-turnstile.spec.ts frontend/src M 23 0 present unchanged_at_HEAD 0 -frontend/src/api/__tests__/client.spec.ts frontend/src M 101 4 present unchanged_at_HEAD 0 -frontend/src/api/admin-payments.ts frontend/src M 16 1 present unchanged_at_HEAD 1 -frontend/src/api/admin.ts frontend/src M 4 2 present unchanged_at_HEAD 0 -frontend/src/api/async-tasks.ts frontend/src M 8 0 present unchanged_at_HEAD 0 -frontend/src/api/auth.ts frontend/src M 16 5 present unchanged_at_HEAD 0 -frontend/src/api/billing.ts frontend/src M 30 0 present unchanged_at_HEAD 0 -frontend/src/api/client.ts frontend/src M 128 43 present changed_after_hardening 10 -frontend/src/api/endpoints/types/provider.ts frontend/src M 8 0 present changed_after_hardening 0 -frontend/src/api/me.ts frontend/src M 1 0 present unchanged_at_HEAD 0 -frontend/src/api/oauth.ts frontend/src M 3 3 present unchanged_at_HEAD 0 -frontend/src/api/proxy-nodes.ts frontend/src M 2 2 present unchanged_at_HEAD 0 -frontend/src/api/wallet.ts frontend/src M 3 4 present unchanged_at_HEAD 0 -frontend/src/components/common/UpdateDialog.vue frontend/src M 4 2 present unchanged_at_HEAD 0 -frontend/src/components/common/VersionButton.vue frontend/src M 4 2 present unchanged_at_HEAD 0 -frontend/src/features/auth/components/LoginDialog.vue frontend/src M 3 4 present unchanged_at_HEAD 0 -frontend/src/features/auth/components/RegisterDialog.vue frontend/src M 19 3 present unchanged_at_HEAD 0 -frontend/src/features/auth/components/__tests__/RegisterDialog.spec.ts frontend/src M 1 0 present unchanged_at_HEAD 0 -frontend/src/features/auth/components/__tests__/RegisterDialog.turnstile.spec.ts frontend/src M 1 0 present unchanged_at_HEAD 0 -frontend/src/features/auth/utils/__tests__/loginRedirect.spec.ts frontend/src M 10 0 present unchanged_at_HEAD 0 -frontend/src/features/auth/utils/loginRedirect.ts frontend/src M 5 3 present unchanged_at_HEAD 0 -frontend/src/features/pool/components/PoolAccountBatchDialog.vue frontend/src M 5 8 present unchanged_at_HEAD 0 -frontend/src/features/providers/components/EndpointConditionEditor.vue frontend/src M 1 0 present unchanged_at_HEAD 1 -frontend/src/features/providers/components/EndpointFormDialog.vue frontend/src M 68 16 present unchanged_at_HEAD 14 -frontend/src/features/providers/components/OAuthAccountDialog.vue frontend/src M 14 2 present unchanged_at_HEAD 0 -frontend/src/features/providers/components/ProviderBatchActionDialog.vue frontend/src M 2 2 present unchanged_at_HEAD 0 -frontend/src/features/providers/components/ProviderDetailHeader.vue frontend/src M 6 4 present unchanged_at_HEAD 0 -frontend/src/features/providers/components/ProviderMobileCard.vue frontend/src M 6 4 present changed_after_hardening 0 -frontend/src/features/providers/components/ProviderTableRow.vue frontend/src M 6 4 present changed_after_hardening 0 -frontend/src/features/providers/components/__tests__/endpoint-rule-condition.secret-markers.spec.ts frontend/src A 56 0 present unchanged_at_HEAD 0 -frontend/src/features/providers/components/__tests__/endpoint-secret-markers.spec.ts frontend/src A 21 0 present unchanged_at_HEAD 0 -frontend/src/features/providers/components/endpoint-rule-condition.ts frontend/src M 13 3 present unchanged_at_HEAD 2 -frontend/src/features/providers/components/endpoint-secret-markers.ts frontend/src A 20 0 present unchanged_at_HEAD 3 -frontend/src/features/usage/components/HorizontalRequestTimeline.vue frontend/src M 7 2 present changed_after_hardening 0 -frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts frontend/src M 16 0 present changed_after_hardening 0 -frontend/src/features/users/components/UserPlanDialog.vue frontend/src M 4 4 present unchanged_at_HEAD 0 -frontend/src/features/wallet/components/WalletOpsDrawer.vue frontend/src M 7 3 present changed_after_hardening 0 -frontend/src/layouts/MainLayout.vue frontend/src M 4 10 present changed_after_hardening 0 -frontend/src/mocks/handler.ts frontend/src M 4 5 present unchanged_at_HEAD 1 -frontend/src/router/guards/__tests__/authGuard.spec.ts frontend/src A 37 0 present unchanged_at_HEAD 0 -frontend/src/router/guards/__tests__/homeGuard.spec.ts frontend/src M 7 0 present unchanged_at_HEAD 0 -frontend/src/router/guards/authGuard.ts frontend/src M 4 0 present unchanged_at_HEAD 0 -frontend/src/router/guards/homeGuard.ts frontend/src M 4 2 present unchanged_at_HEAD 0 -frontend/src/router/index.ts frontend/src M 7 4 present unchanged_at_HEAD 1 -frontend/src/stores/__tests__/auth.spec.ts frontend/src M 44 1 present changed_after_hardening 0 -frontend/src/stores/auth.ts frontend/src M 75 11 present changed_after_hardening 13 -frontend/src/utils/__tests__/billingEntitlements.spec.ts frontend/src A 158 0 present unchanged_at_HEAD 0 -frontend/src/utils/__tests__/crossTabRefresh.spec.ts frontend/src M 217 15 present changed_after_hardening 0 -frontend/src/utils/__tests__/navigationSecurity.spec.ts frontend/src A 67 0 present changed_after_hardening 0 -frontend/src/utils/__tests__/oauth-icons.spec.ts frontend/src A 33 0 present unchanged_at_HEAD 0 -frontend/src/utils/__tests__/paymentUrl.spec.ts frontend/src A 29 0 present unchanged_at_HEAD 0 -frontend/src/utils/__tests__/sanitize.spec.ts frontend/src M 14 0 present unchanged_at_HEAD 0 -frontend/src/utils/billingEntitlements.ts frontend/src A 115 0 present changed_after_hardening 2 -frontend/src/utils/crossTabRefresh.ts frontend/src M 158 16 present changed_after_hardening 5 -frontend/src/utils/navigationSecurity.ts frontend/src A 61 0 present changed_after_hardening 8 -frontend/src/utils/oauth-icons.ts frontend/src M 34 1 present unchanged_at_HEAD 4 -frontend/src/utils/paymentUrl.ts frontend/src A 12 0 present unchanged_at_HEAD 4 -frontend/src/utils/sanitize.ts frontend/src M 27 2 present unchanged_at_HEAD 1 -frontend/src/views/admin/AsyncTasks.vue frontend/src M 55 11 present changed_after_hardening 1 -frontend/src/views/admin/BillingPlansManagement.vue frontend/src M 878 46 present unchanged_at_HEAD 5 -frontend/src/views/admin/ProxyNodes.vue frontend/src M 2 1 present unchanged_at_HEAD 0 -frontend/src/views/admin/ReferralManagement.vue frontend/src M 0 1 present changed_after_hardening 0 -frontend/src/views/admin/Users.vue frontend/src M 30 3 present unchanged_at_HEAD 0 -frontend/src/views/admin/WalletsManagement.vue frontend/src M 8 4 present changed_after_hardening 0 -frontend/src/views/admin/system-settings/AggregateImportDialog.vue frontend/src M 1 1 present unchanged_at_HEAD 0 -frontend/src/views/admin/system-settings/BasicConfigSection.vue frontend/src M 1 0 present unchanged_at_HEAD 0 -frontend/src/views/admin/system-settings/ConfigImportDialog.vue frontend/src M 1 1 present unchanged_at_HEAD 0 -frontend/src/views/admin/system-settings/UsersImportDialog.vue frontend/src M 2 2 present unchanged_at_HEAD 0 -frontend/src/views/admin/system-settings/__tests__/useSystemConfig.spec.ts frontend/src M 1 1 present changed_after_hardening 0 -frontend/src/views/admin/system-settings/composables/useSystemConfig.ts frontend/src M 1 1 present unchanged_at_HEAD 0 -frontend/src/views/public/AuthCallback.vue frontend/src M 6 15 present unchanged_at_HEAD 0 -frontend/src/views/public/__tests__/AuthCallback.spec.ts frontend/src A 79 0 present unchanged_at_HEAD 0 -frontend/src/views/public/guide/components/MarkdownViewer.vue frontend/src M 2 2 present unchanged_at_HEAD 2 -frontend/src/views/user/BillingPlans.vue frontend/src M 48 23 present changed_after_hardening 0 -frontend/src/views/user/Settings.vue frontend/src M 17 16 present changed_after_hardening 1 -frontend/src/views/user/WalletCenter.vue frontend/src M 48 8 present changed_after_hardening 1 -generate_keys.sh root-config M 9 0 present changed_after_hardening 0 -install.sh root-config M 934 144 present changed_after_hardening 0 -tests/deploy_state_safety_test.sh tests A 59 0 present unchanged_at_HEAD 0 -tests/install_archive_safety_test.sh tests A 49 0 present unchanged_at_HEAD 0 -tests/install_container_runtime_security_test.sh tests A 203 0 present changed_after_hardening 0 -tests/install_current_release_link_test.sh tests A 193 0 present unchanged_at_HEAD 0 -tests/install_local_bundle_safety_test.sh tests A 68 0 present unchanged_at_HEAD 0 -tests/install_privileged_write_safety_test.sh tests A 180 0 present unchanged_at_HEAD 0 -tests/install_source_trust_test.sh tests A 36 0 present unchanged_at_HEAD 0 -tests/release_supply_chain_test.sh tests A 68 0 present changed_after_hardening 0 -tests/tunnel_installer_config_security_test.sh tests A 221 0 present unchanged_at_HEAD 6 -tests/update_compose_safety_test.sh tests A 134 0 present unchanged_at_HEAD 0 -update.sh root-config M 19 5 present unchanged_at_HEAD 0 diff --git a/docs/operations/security-hardening-compatibility-review.md b/docs/operations/security-hardening-compatibility-review.md deleted file mode 100644 index 38e56abf5..000000000 --- a/docs/operations/security-hardening-compatibility-review.md +++ /dev/null @@ -1,98 +0,0 @@ -# 安全加固兼容性复核(2026-09-07) - -后续扩展到全仓的检查范围、新发现、逐文件清单及限制见 `security-hardening-full-audit.md` 和 `security-hardening-audit-manifest.tsv`。 - -## 范围 - -本轮针对 `579f2c7cc`(2026-09-04)及其后续修复进行复核。该提交涉及 1019 个文件,混合了安全边界、订阅计费、数据持久化和运行时变更,不适合整体 revert。 - -本轮重点检查管理端脱敏与编辑往返、Provider OAuth 默认配置、已有 DNS/代理兼容性修复、管理员错误诊断、SMTP/LDAP/OAuth 配置、导入导出及请求大小限制。以下是已确认的问题及处理,不代表对全部文件逐行审计或对生产环境的完整验证。 - -## 新增:管理员按需查看规则原值 - -- 端点管理的请求/响应规则工具栏增加“查看原值”。只读弹窗展示服务端已保存的请求头、请求体和响应头规则,不覆盖未保存的草稿。 -- 新接口:`GET /api/admin/endpoints/{endpoint_id}/rules/reveal`。 -- 仅管理员可访问;管理令牌需要 `admin:endpoints_manage:admin`,只读和普通写权限不足。 -- 返回 `Cache-Control: no-store`、`Pragma: no-cache`,记录 `admin_endpoint_rules_revealed` 审计事件。响应仅包含三组规则,不附带端点的其他配置或凭据。 -- 关闭弹窗、切换端点或卸载组件时取消请求并清除明文;旧请求的迟到结果不会重新显示。 - -## 本轮修复的六类误伤 - -| 问题 | 影响 | 处理 | -| --- | --- | --- | -| 普通协议头也全部脱敏 | `Content-Type`、`User-Agent`、版本/客户端信息及相应条件无法正常查看;配置导出也受影响 | 恢复明确的常用非凭据头的显示;认证头、Cookie 和未知自定义头继续默认隐藏。旧版本返回的保留标记仍可正常保存 | -| 重复规则无法恢复原值 | 同一头或路径配置多条条件规则后,保存或重排可能将原值变成 `***` | 在相同操作和目标范围内按可区分的规则结构匹配;未修改的重复规则保留原顺序与原值。无法唯一定位的脱敏编辑明确报错,禁止静默写入占位符 | -| 嵌套条件组丢失原值 | 多层 `all`/`any` 在修改其他条件或重排后,内部脱敏值无法恢复 | 增加递归条件组匹配、未修改条件组往返保留和保留值校验 | -| URL 类型判断过宽 | `image_url` 等对象/数组被变成 `null`,内嵌 `data:` 图片 URL 也受影响,继续编辑可能破坏请求规则 | 保留结构化 URL 和内嵌数据;递归处理网络 URL,继续隐藏并在保存时恢复网络凭据 | -| 保存后的编辑状态没有同步 | 后台返回脱敏数据后,界面仍保留提交前明文,持续显示“未保存” | 使用保存响应重新建立编辑基准;保存期间的新编辑不被覆盖,关闭后清除草稿,忽略跨弹窗的旧响应 | -| Gemini CLI 默认 OAuth 被误移除 | 未额外配置环境变量时,原有默认客户端不能授权或刷新 | 恢复加固前内置 native-app 客户端的配套默认值;显式配置优先,自定义客户端 ID 仍必须提供自己的凭据,调试输出继续脱敏 | - -明确可直接显示的头包括 `Accept`/编码/语言、`Content-Type`/编码、`Cache-Control`、`User-Agent`、Anthropic/OpenAI 协议版本与 beta 标记,以及明确列出的 `x-stainless-*` 客户端运行时字段;不是按前缀放行任意自定义头。 - -## 补充修复:显式 `full` 请求记录被禁用 - -原则:安全处理应约束未授权访问、未配置时的默认行为和真实凭据泄露,不应静默覆盖管理员明确启用的功能。 - -本次确认不是单纯的前端隐藏,而是同一链路上的多重清空: - -- `request_record_level=full` 在运行时被强制解析为 `basic`。 -- 独立 HTTP 审计存储的输入投影清除了所有请求头、正文、正文引用和采集状态。 -- 内存仓库每次更新都丢弃正文与引用;PostgreSQL 的单条写入把正文 blob 同步改成删除,并拒绝 HTTP 审计内容。 - -处理:恢复显式 `full`(含旧配置名 `request_log_level`)的采集与独立存储,保留当前配置名优先;恢复请求、上游请求、上游响应和客户端响应各方向已有的采集内容,流转状态更新不再无条件删除它们。PostgreSQL 正文仍写入原有压缩 blob 表,HTTP 头与引用仍写入独立审计表,不重新塞回计费主表。 - -保留:缺失、无效配置和读取配置失败时默认 `basic`;`basic` 不记录正文;OAuth 令牌交换等内部凭据请求不采集正文;认证头及未知自定义头继续脱敏;正文引用仍校验所属请求和字段;管理端权限、审计及无缓存策略不变。普通同格式非流式响应仍只保存一份原有响应体,前端沿用回退显示,不制造重复的客户端副本。 - -新增回归覆盖配置解析、内存生命周期、流式/非流式管理端正文读取、旧正文大小配置不覆盖 `full`、PostgreSQL 单条/批量写入与正文读回、前端按需加载与无正文时的展示。真实数据库测试使用本机临时隔离 PostgreSQL,不访问现有业务数据库。 - -此修复只能恢复之后新采集的记录;此前未采集或已删除的正文无法由代码补回。运行时沿用原有的 30 秒采集策略缓存,刚切换记录级别时需等待缓存刷新。 - -扩大执行原有、默认忽略的 PostgreSQL 测试时,另发现 5 个旧用例的测试数据/类型与当前 schema 不兼容:4 个使用超过 `varchar(36)` 限制的 Provider Key ID,1 个直接按 `f64` 解码 `NUMERIC` 列。它们并非本次正文修复引入,也不作为本次已通过项;未修改相关业务逻辑或测试夹具。 - -## 继续复核:视频任务链路的四类回归 - -继续沿“业务字段被当作诊断数据清空”“读取投影与更新条件不一致”检查,另确认以下四类问题,均可定位到本次加固新增的清空或校验条件,不是用户配置错误: - -| 问题 | 实际影响 | 本轮修复 | -| --- | --- | --- | -| 任务业务信息被清空 | 提示词、用户名和客户端 Key 名称丢失,管理页显示空白或 `Unknown`;提示词被写成 `NULL` 还与 PostgreSQL 的非空约束冲突,可能直接阻止任务入库 | 保留这些明确用于任务展示的业务字段,不再作为敏感诊断一律删除 | -| 下载地址被清空或破坏签名 | OpenAI 任务的 `video_url` 被无条件丢弃;Gemini 地址仅保留 `alt=media`,使下载签名、有效期等参数失效 | 保留 HTTP(S) 产物地址及完整查询串,不重排重复参数、不重编码签名;继续拒绝非法协议和 URL userinfo,移除 fragment | -| Gemini 完成结果和重载丢失 | 完成轮询直接清空元数据;数据库重建与加密文件加载再次清空结果 URI,出现任务已完成但无法取得视频 | 仅保留下载所需的最小结果 URI,不保留上游完整响应;从数据库的业务字段重建必要的提示词、尺寸信息和结果 URI,保证再次保存不会丢失 | -| PostgreSQL 轮询更新条件自相矛盾 | 领取任务时不读出时长、分辨率、宽高比和尺寸,而加固后的更新要求这些不可变字段逐项相等,导致正常轮询更新被拒绝、任务停留在处理中 | 领取时读回必要业务字段,保留原有归属/身份校验和并发领取锁,不靠放宽更新条件绕过问题 | - -保留原有播放/下载接口与鉴权语义:签名产物链接是有权限用户访问任务结果所需的业务数据,不等同于可随意删除的调试凭据。OpenAI 直接下载不附带 Provider 认证头;Gemini 仍通过网关文件接口访问,并校验上游地址同源后才附加 Provider Key。私网/保留地址拦截、跨域凭据隔离、任务归属校验及管理操作审计不撤销。 - -任务数据库仍不保存完整请求体、原始错误消息或含认证头的传输快照;本地文件仍使用原有认证加密格式,调试输出继续脱敏。回归覆盖任务保存后重读、签名参数顺序与编码、加密文件重载、管理列表/详情、带审计的下载,以及轮询前后提示词和尺寸不丢失。 - -真实 PostgreSQL 回归使用临时 Unix socket 实例,并从正式迁移后的 schema 复制会话隔离的任务表。OpenAI/Gemini 两种任务均验证了入库、领取、正常完成更新、拒绝不匹配的尺寸更新和重新查询签名地址;不连接现有业务数据库。 - -这四类是本轮已经确认的新增回归,不代表对混合提交全部 1019 个文件给出“零遗漏”保证。原有用户策略字段停用早于本次加固,不据此回退;后台任务和缓存中的裁剪未发现足以确认本次功能回归的调用链,不做猜测性恢复。数据库仍保存的历史值可以重新读取;已经写空、未入库或已过期的资源不能凭本次源码修复重建。 - -## 已存在的后续修复:保留,不重复撤销 - -- `522b97905`:已移除普通 Provider 代理的 DNS 地址过滤和相关白名单设置。 -- `696273122`:已恢复 Antigravity 默认 native-app OAuth 配套凭据;本轮将同类遗漏补到 Gemini CLI。 -- `a26680f46`:已恢复旧 SMTP 密码迁移。 -- `062e111c0`:已恢复管理员查看上游错误诊断的能力,同时保留普通用户侧脱敏。 -- `14f96c9fa` 等:已修复脱敏后的密钥健康状态摘要,不将其退回旧实现。 - -## 保留的安全边界 - -- 管理员/普通用户与管理令牌的权限隔离;凭据查看接口的审计和无缓存要求。 -- 凭据加密存储及凭据与目标/身份绑定;未知头和真实敏感字段的默认脱敏。 -- 登录 OAuth、支付、隧道中继等独立网络边界,TLS 校验和隧道防重放。 -- 有界请求/响应缓冲、备份恢复模式、导入校验、计费与配额一致性;本轮没有因“安全加固”标签撤回这些功能。 - -## 回归覆盖与使用限制 - -回归用例覆盖规则投影/恢复、重复与嵌套规则、占位符拒绝、结构化 URL、保存期间的并发编辑、弹窗关闭/切换时的迟到响应、接口权限/审计/无缓存,以及 Gemini CLI 和 Antigravity 的默认配置与显式覆盖。 - -早期兼容性修复的阶段性验证记录(最终全量结果见 `security-hardening-full-audit.md`): - -- 视频数据契约 12 项、视频核心 33 项(含加密文件重载)、内存视频仓库 11 项、PostgreSQL 视频仓库 9 项、Provider 视频传输 9 项均通过。 -- 新增的真实 PostgreSQL 回归单独执行通过,同时覆盖 OpenAI 和 Gemini;临时数据库实例已停止并清理。 -- 网关 `video` 101 项、`async_task::` 20 项、`usage` 302 项、端点规则原值查看 3 项、管理令牌权限 28 项均通过;筛选结果可能重叠,不合计为独立用例总数。 -- 前端 199 个测试文件、1411 项测试通过;补齐保存测试的类型夹具后,该文件 4 项测试及 ESLint 再次通过;Rust 格式检查和 `git diff --check` 通过。 -- 阶段性检查曾发现 461 条既有类型诊断,与未修改 HEAD 的同依赖基线一致。2026-09-07 的“全部修复”续轮已修正这些诊断,并将 `npm run type-check` 接入 `vue-tsc -b --force --pretty false`;当前真实全量类型检查为 0 错误。最新全量测试、构建及剩余验证边界以 `security-hardening-full-audit.md` 为准,未关闭严格模式或排除测试。 - -上述修复与回归不涉及生产服务器部署或现有业务数据库更改;源码提交、推送不代表生产环境已经部署或完成验证。若历史版本已经把真实值覆盖为字面量 `***`,查看接口不能重建丢失的原值,需重新填写或从可靠备份恢复。 diff --git a/docs/operations/security-hardening-full-audit.md b/docs/operations/security-hardening-full-audit.md deleted file mode 100644 index 1e8a3a7fd..000000000 --- a/docs/operations/security-hardening-full-audit.md +++ /dev/null @@ -1,134 +0,0 @@ -# 安全加固全仓兼容性复核(2026-09-07) - -## 范围与方法 - -- 加固基线:`579f2c7cc`(2026-09-04);复核时 HEAD:`a5c3699ae`。保留已有未提交的兼容性修复,不整体回退该混合提交,也不撤销后续修复。 -- 历史提交涉及 1019 个文件,其中 907 个路径仍存在,112 个已在后续重构中移除。逐路径清单见 `security-hardening-audit-manifest.tsv`;已移除的旧数据库适配器、未发布迁移等不凭历史清单重新引入。 -- 对复核时 Git 跟踪的 2843 个 Rust、TypeScript、Vue、JavaScript、Python、SQL 和 Shell 源文件进行清单、读取与模式扫描,并检查加固差异、后续修订及关键生产调用链。正文清空、无条件禁用、脱敏、URL 拒绝、权限与数据归属等是重点搜索模式。 -- 清单中的 `heuristic_added_risk_lines` 是启发式候选行数,含初始化代码和内联测试,**不是 Bug 数、漏洞数或逐行人工审计完成标记**。全仓扫描、模块复核、回归测试是不同的覆盖层次,不等于人工精读所有源文件,也不承诺零遗漏。 -- 判断标准:恢复管理员明确启用的功能和必要业务数据;保留未授权访问阻断、默认保护、凭据隔离和有界资源使用。不因一条旧测试要求“所有内容都为空”,就把正常功能重新禁用。 - -之前的规则、OAuth、`full` 记录及视频四类修复详见 `security-hardening-compatibility-review.md`。本报告只把本次扩展检查的新发现单独计数。 - -## 本次新增确认并修复的两类加固回归 - -### 1. 正文的“压缩保留”被替换成删除 - -涉及 `crates/aether-data/adapters/postgres/src/usage/cleanup.rs`。 - -原本分为详细正文、压缩正文、请求头、计费记录等独立保留周期。加固后,详细正文清理不再压缩迁移,而是删除正文、独立 blob 与审计引用;选择条件还包含已经独立存储的正文。因此采用默认 7/30 天设置时,明确启用 `full` 后保存的正文也会在约 7 天后提前失去,而不是按压缩正文的 30 天保留。 - -修复内容: - -- 详细正文到期只处理主表中的旧内联/压缩数据,将四个方向的正文迁入独立 gzip blob,并保留 HTTP 审计引用;不选择已经迁出的正文反复处理。 -- 旧 metadata 中的正文引用按当前请求和字段校验后迁入审计表,删除旧 metadata 键,但不删除实际正文。无效、跨请求及跨字段引用不恢复。 -- 每条迁移在事务中锁定并重读来源行。正文写入或引用更新失败时回滚,不先清空原值;其他 metadata 内容保留。 -- 预览统计与实际清理选择条件一致。压缩正文到期仍删除;明确选择“立即清理正文”的操作仍执行删除,未把隐私清理功能改成永远保留。 - -真实 PostgreSQL 回归使用正式迁移后的 schema 与会话隔离的临时表,覆盖四个方向的内联 JSON、旧 gzip、独立 blob、旧引用迁移、外部请求引用拒绝、7/30 天区间、过期删除、重复清理幂等、显式即时删除和中途写入失败后的事务回滚。不会读取或修改业务数据库。 - -同时修复了该迁移路径原有的 SQL 错误:`usage` 表没有 `updated_at` 列,旧更新语句却写入它,真实 schema 下会导致迁移失败。这个错误在加固前已经存在,**不混算成第三类加固新增回归**。 - -### 2. 合法支付会话链接的 fragment 被误拒绝 - -涉及 `frontend/src/utils/paymentUrl.ts`,调用方包括钱包充值和订阅购买。 - -加固把包含 `#fragment` 的 HTTPS 支付链接一律拒绝。支付会话链接可能依赖该片段,不能将其等同于脚本协议或 URL 内嵌凭据。现在保留原有片段、查询参数及编码,同时继续拒绝非 HTTPS、相对地址、反斜杠和 URL 用户名/密码。 - -对应工具函数新增会话片段和编码保留测试,危险协议及凭据注入用例继续执行。服务器端支付 API 基址的同源、TLS 和无片段限制不变;没有放宽 webhook 验签、支付金额、订单归属或实际支付状态校验。此次不进行真实扣款测试。 - -## 按模块的复核边界 - -| 模块 | 复核内容与处理 | -| --- | --- | -| 公共入口、认证、会话、管理令牌 | 核对路由分类、认证缓存刷新、跨节点撤权和令牌权限目录;保留身份头防伪、用户/管理员隔离及敏感动作的权限要求。 | -| 端点配置及编辑往返 | 保留前序规则原值查看、重复/嵌套规则恢复、占位符拒绝和并发编辑保护,避免只恢复显示而破坏再次保存。 | -| 普通 Provider 连接与代理 | 对照后续 DNS/FakeIP/SOCKS 修复,不重新引入已移除的普通 Provider 地址过滤;凭据专用连接与普通业务代理分开判断。 | -| OAuth、SMTP、LDAP 与密钥 | 检查默认客户端、显式覆盖、凭据迁移与目标绑定;保留 Gemini CLI/Antigravity 兼容性恢复及既有 SMTP 修复,不撤销传输凭据隔离。 | -| 请求记录、候选诊断及保留策略 | 核对配置读取、采集、投影、单条/批量写入、读取和清理链路;修复本报告的提前删除,保留显式 `full` 及必要诊断,不把原始正文重新塞回计费主表。 | -| 视频与异步任务 | 保留前序提示词/展示名、签名产物 URL、Gemini 结果 URI、数据库领取字段修复;身份不可变条件、任务归属、加密文件和下载凭据隔离不撤销。 | -| 模型、调度、配额和池状态 | 对照模型获取、手动模型、健康摘要、候选选择、额度预留及状态流转;不回退混入该提交的订阅/计费功能。 | -| 钱包、订阅、兑换与支付 | 检查业务 URL 到前端跳转链路并修复 fragment 误拦;交易归属、金额、并发更新、幂等及回调校验维持原边界。 | -| 流式、WebSocket、格式转换 | 检查请求限制、认证头转发、取消/重定向、会话延续和正文捕获之间的关系;修正两处与恢复后的日志语义冲突的旧断言,不退回禁用功能的实现。 | -| 系统导入、导出、备份和恢复 | 区分交互式导入、恢复备份、回滚模式;保留加密备份、用途绑定、导入锁和凭据保留规则,不把安全导出误当作完整灾备。 | -| 数据契约、PostgreSQL 和迁移 | 检查脱敏投影与业务字段消费者是否矛盾;旧驱动不复活,使用真实迁移 schema 验证正文及视频的关键写入链路。 | -| 管理前端与外链 | 复核脱敏状态、权限显示、URL/导航校验、支付调用方,并执行全部前端测试与真实项目类型检查。 | -| 安装、更新、容器和发布流程 | 执行归档、链接、目录、写入目标、来源信任、compose 参数及发布供应链的隔离 Shell 测试;不运行实际部署。 | - -## “全部修复”续轮完成项 - -以下问题已按根因处理,不再作为上一轮的未解决清单。历史类型问题、测试夹具与辅助器缺陷不统一归因为本次加固,功能恢复也不以撤销所有保护为代价。 - -### 1. 真实前端类型检查及相关运行时缺陷 - -- 修复原先 116 个文件中的 461 条类型诊断,将 `npm run type-check` 改为 `vue-tsc -b --force --pretty false`,确实检查引用的应用和工具项目,不再依赖顶层空项目的成功退出。 -- 按现有接口契约补齐响应泛型、可空字段、配置 schema、图表时间轴、用户角色、事件参数及测试夹具。保留严格检查、ES2021、未知数据边界与动态配置扩展字段,未通过 `any`、忽略诊断、排除测试或降低配置绕过错误。 -- 修复请求缓存和登录校验在 fetcher 同步抛错后不能正确清理 in-flight 状态的问题;失败后可按原有退避策略重试,仍隔离不同身份和旧请求。 -- 修复 Provider 余额重试的迟到响应覆盖新加载结果,以及卸载后更新状态的问题;新增回归同时覆盖合法的零余额与签到失败值,不因真假值判断隐藏正常数据。 -- 修复钱包模板中的刷新调用,并在用户密钥删除、路由配置保存等异步流程中固定操作目标,避免确认期间切换页面后作用到另一个对象。 - -### 2. 手动保留天数取了更激进的截止点 - -`usage_cleanup_window_with_override` 原来使用 `max` 合并截止时间,与界面“在策略内取更保守时间点”的设计相反:删除条件是记录时间早于截止点,选更晚的时间会扩大删除范围。 - -现改为每一保留层级分别取 `min`。详细正文、压缩正文、请求头、完整日志均不短于既有策略,也不短于本次手动指定天数。测试覆盖 0、5、30、180、400 天及“选中记录是原策略子集”的关系。显式立即清理模式不受此改动影响。 - -### 3. 可信原始数据库恢复可显式保留凭据 - -原始 JSONL 导入及数据库复制曾无条件替换密码哈希、API Key 和管理令牌,导致可信恢复/迁移也不能继续使用原凭据。新增 CLI `--preserve-credentials`,仅由操作者显式选择,不能由导入文件中的字段自行启用: - -```sh -aether-gateway --database-driver postgres --postgres-url "$TARGET_DATABASE_URL" import --input /path/to/trusted.jsonl --preserve-credentials -aether-gateway copy --source-driver postgres --source-url "$SOURCE_DATABASE_URL" --target-driver postgres --target-url "$TARGET_DATABASE_URL" --preserve-credentials -``` - -- 不加该参数时仍默认撤销导入的身份凭据,并在操作前给出警告;原有库函数入口也保持该默认值。 -- 显式保留时,只保留导入文件中用户密码、API Key 和管理令牌原有字段/状态;不会把原本停用的凭据重新启用。 -- 导入的登录会话仍撤销,代理隧道仍重置在线状态和代际。外键、身份归属、OAuth 绑定一致性、输入大小及事务校验不变。 -- 仅适用于可信且由操作者控制的备份/来源。目标实例须使用与来源兼容的加密配置;此开关不会解密、重新加密或重建已经丢失的值。复制链接的 TLS 默认保护不变。 -- 这是原始数据库工具的选项,不改变 HTTP 配置导入权限,也不替代已有的加密备份恢复模式。操作示例会写入指定目标,执行前须自行核对目标并备份;本轮仅在临时隔离库中验证。 - -真实 PostgreSQL 回归分别验证默认撤销与显式保留后的密码哈希、密钥哈希、密文和启用状态;单元回归另覆盖管理令牌、会话及隧道边界。 - -### 4. 数据库回归夹具与测试资源回收 - -- 修正历史测试的超长 ID、NUMERIC/f64 解码、依赖预填充数据库的统计夹具,以及使用不在持久化契约内的 metadata 字段。使用真实 UUID、显式 SQL 类型转换、自建自清理数据和合法 trace 字段,未放宽生产 schema 或脱敏规则来迁就测试。 -- 保留现有 usage 导出时间戳以秒计的兼容契约,未因旧字段名包含 `_ms` 就改变导入导出单位。 -- 临时 PostgreSQL 辅助器改为 `pg_ctl stop -m fast` 并等待子进程退出,再清理自有目录;关闭失败时保留进程与目录的所有权,允许重试,不强杀后直接删数据。 -- 新增两个真实 PostgreSQL 回归,覆盖打开连接下四次停止/重启、数据保留、重复停止、关闭失败重试及析构清理。专项运行前后共享内存段清单未新增残留;未清除其他业务或历史进程的 IPC 资源。 -- 续轮全量回归进一步定位到迁移、回填测试各自复制的旧辅助器,同样在强杀后泄漏资源,导致后续网关测试无法初始化数据库。两份实现已合并到仅测试使用的 `postgres_test_support.rs`,采用相同的正常关闭与失败重试规则,另补两个回归;没有只清理环境而保留泄漏代码。 -- 用 `AETHER_REQUIRE_LOCAL_POSTGRES_TESTS=1` 强制迁移、回填和共用辅助器的真实数据库用例执行,生命周期筛选结果为 63 通过、0 失败、1 忽略;该忽略项为已在隔离数据库中单独通过的导入测试。此次完整生命周期运行前后 IPC 清单一致。 -- 辅助器支持 `AETHER_PG_CTL_BIN`,默认查找 `AETHER_POSTGRES_BIN` 同目录下的 `pg_ctl`;初始化失败也回收本次拥有的工作目录。 - -## 验证结果 - -| 验证层 | 结果 | -| --- | --- | -| Rust 工作区所有 library 与 binary 测试目标 | 最终 `cargo test --workspace --lib --bins --locked -- --test-threads=4`:65 个目标,8944 通过、0 失败、19 默认忽略;其中 41 个 library 目标 8645 通过,24 个 binary 目标 299 通过。设置 `AETHER_REQUIRE_LOCAL_POSTGRES_TESTS=1`,迁移/回填测试不能因环境问题静默跳过。 | -| 最终网关全量 | 随工作区 feature 合并执行:5124 通过,0 失败,含此前因数据库初始化失败的两项跨节点认证回归。使用仓库既有的 `RUST_MIN_STACK=16777216` 测试配置;不直接运行遗漏该配置的产物,也不改生产配置规避问题。 | -| PostgreSQL 适配器全量含真实数据库回归 | `AETHER_TEST_DATABASE_URL=<隔离库> cargo test -p aether-data-postgres --lib --locked -- --include-ignored --test-threads=1`:232 通过,0 失败,0 忽略,包含原先默认忽略的 16 项。 | -| 原始数据库导入/导出 | 设置独立的 `AETHER_TEST_POSTGRES_URL` 并执行 `lifecycle::export::tests --include-ignored`:18 通过,0 失败,0 忽略,包含迁移后数据库读取及两种凭据策略的真实往返。 | -| 临时 PostgreSQL 关闭/重启 | `aether-testkit --features postgres` 的两个真实数据库专项回归均通过;四次打开连接下重启、关闭失败重试、目录与共享内存回收均已验证。 | -| WebSocket 真实网关集成 | 最终重新执行 11 通过、0 失败,含跨连接继续对话、连续计费、长连接撤权、认证头隔离、未知字段透传、断开结算、额度重试和 PII 恢复。使用临时 PostgreSQL 与模拟上游。 | -| 可执行入口 | 299 项通过中包括网关主入口 61、隧道 185、备份恢复 CLI 10、两种 WebSocket 探针 3/4,以及压力测试种子/探针、模拟上游等入口测试。没有启动这些工具的实际生产操作。 | -| 额外入口与身份隔离集成 | `aether-data --test public_entrypoints` 3 项、`aether-gateway --test admin_unsigned_identity_headers` 1 项均重新执行通过。 | -| 前端全量与构建 | 最终 `npm run test:run`:200 个测试文件、1419 项测试通过;`npm run build:with-typecheck` 构建通过。 | -| 类型与格式 | `npm run type-check` 实际运行 `vue-tsc -b --force --pretty false`:0 错误,原先 461 条诊断均已修正;142 个改动/新增前端源码文件 ESLint 为 0 错误、0 警告。`cargo fmt --all -- --check` 和 `git diff --check` 通过。 | -| 提交前 Clippy | 本地按 `.github/workflows/rust-ci.yml` 的 Gateway、Data、其余工作区三组范围执行,均使用 `--locked` 和 `-D warnings`,全部通过;未关闭或放宽 lint 规则。 | -| 安装/发布脚本 | 10 份隔离 Shell 测试全部通过;`python3 tests/compose_database_config_test.py` 通过,只渲染配置,不启动容器。未实际安装、升级或部署。 | - -工作区默认忽略的 19 项全部另行显式执行通过:PostgreSQL 适配器 16 项、真实导入 1 项、测试辅助器 2 项。默认命令中的忽略不计作通过;工作区 feature、单包测试及过滤测试的范围不同且存在重叠,上表不累加成独立用例总数。此前的规则、完整正文单条/批量持久化和视频真实数据库回归结果保留在兼容性复核报告中。 - -中间轮次曾因旧测试辅助器留下的 IPC 残留导致 2 项网关测试和随后 10 项 WebSocket 测试初始化失败;没有把这些命令记为成功。现已修正三处辅助器的关闭路径,并只清理本轮确认创建、无连接且创建进程已退出的 11 个段,未清理此前已有的 21 个段,也未修改内核共享内存限制。 - -最终工作区、WebSocket、公开入口和身份隔离测试顺序运行前后的 IPC 清单一致,均为此前已有的 21 个段,无新增残留。单独用于数据库适配器及导入往返的临时 PostgreSQL 已正常停止并删除本次自有目录。 - -脚本范围:`tests/deploy_state_safety_test.sh`、`tests/install_archive_safety_test.sh`、`tests/install_container_runtime_security_test.sh`、`tests/install_current_release_link_test.sh`、`tests/install_local_bundle_safety_test.sh`、`tests/install_privileged_write_safety_test.sh`、`tests/install_source_trust_test.sh`、`tests/release_supply_chain_test.sh`、`tests/tunnel_installer_config_security_test.sh`、`tests/update_compose_safety_test.sh`。 - -## 交付限制 - -本轮已确认、可复现且可在本仓库修复的剩余问题均已处理,未保留已确认却未修复的本轮源码问题;这一结论不等于“所有代码及所有部署环境绝无未知缺陷”。 - -这是本地源码、静态检查与隔离回归,不是对生产配置、第三方账户或所有部署组合的认证。上述验证未执行服务器部署或改动现有业务数据库;后续源码提交、推送不代表已经部署或完成生产环境验证。 - -已经被旧代码写空、删除、未采集的正文或视频字段不能由修复自动重建;仍在数据库中的旧内联/压缩正文可在修正后的保留链路中迁移。真实第三方 OAuth、支付、对象存储和远程隧道服务未使用生产凭据进行端到端验证。 diff --git a/docs/operations/system-slimming-audit-2026-09-08.md b/docs/operations/system-slimming-audit-2026-09-08.md deleted file mode 100644 index 356fd866c..000000000 --- a/docs/operations/system-slimming-audit-2026-09-08.md +++ /dev/null @@ -1,259 +0,0 @@ -# Aether 系统、测试与 CI 瘦身审计 - -- 审计日期:2026-09-08(Asia/Shanghai) -- 源码基线:`8b766930b` -范围:仓库结构、依赖图、本地构建产物、GitHub Actions 实际日志、测试与发布边界。 - -本次只新增审计报告,没有修改业务代码、测试或工作流,没有清理文件,也没有访问生产服务器。没有采集生产 RSS、CPU、数据库体积或请求延迟,因此下文不能作为生产内存泄漏或吞吐退化的结论。 - -## 一、结论 - -优先减掉的是**重复构建、过重的测试夹具、没有实际用例的构建任务和失效的模块边界**,不是先删功能或减少回归断言。 - -- 普通 Rust CI 最近 30 条记录中,20 次成功运行的总耗时中位数约 **16 分 33 秒**,范围 **11 分 42 秒~19 分 03 秒**。样本覆盖 2026-09-05~2026-09-08,包含 push 和 PR;没有将失败、取消或未完成运行计入。 -- 成功样本中 Gateway 是关键路径:一次耗时 **11 分 39 秒**,另一次 **16 分 46 秒**;既有巨型测试目标编译,也有数分钟测试执行,不能只归咎于缓存或测试数量。 -- “Workspace Rest” 虽然排除了 Gateway 测试目标,仍经由 Tunnel 的开发依赖编译完整 Gateway。分 job 没有实现真正的依赖隔离。 -- Nightly 文档测试花了 **252 秒**,41 个库实际执行 **0 个 doctest**;发布默认编译 4 个二进制,只上传其中 1 个。 -- 本地 `target/` 约 **168GB**,其中 `debug/incremental/` 约 **116GB**。这是构建缓存,不是生产镜像或业务源码体积。 - -所有改进收益都需要前后对照验证。本文不会把并行 job 的节省简单相加为流水线总时长,也不会承诺尚未测量的提速比例。 - -## 二、实测基线 - -### 2.1 CI 运行记录 - -数据来自 `fawney19/Aether` 的 GitHub Actions API、各 job 的步骤时间戳及原始日志。 - -| 运行 ID | 类型、源码 | 观察结果 | -| --- | --- | --- | -| `34174603131` | Rust CI,`7113d04f`,成功 | 总耗时 11:55;17 个 job 累计执行 39:19 | -| `34153166516` | Rust CI,`099b810a`,成功 | 总耗时 17:02;17 个 job 累计执行 46:10 | -| `34132961583` | 正式发布,`v0.7.17`,成功 | 总耗时 33:25;最慢为 macOS Intel 构建 | -| `34163371099` | Nightly,`099b810a`,失败 | 总耗时 47:42;检查和编译成功,GHCR 发布 job 在启动阶段失败 | - -普通 CI 的总耗时按 `updated_at - run_started_at` 统计,包含调度和收尾;各 job 耗时按自身开始、完成时间统计,累计值不是计费分钟。详细成功样本与本地基线的 Rust CI、Nightly、Release、Cargo profile 和 Vitest 配置没有差异,业务改动会影响测试数量与耗时。 - -`34174603131` 中各主要测试步骤: - -| 任务 | 编译/链接 | 测试执行 | 步骤或 job 耗时 | -| --- | --- | --- | --- | -| Gateway lib | 4:57 | 5,139 个测试,213.807 秒 | Test lib 步骤 514 秒 | -| Gateway bins | 2:27 | 78 个测试,0.270 秒 | Test bins 步骤 151 秒 | -| Workspace Rest | 5:36 | 3,377 个测试,26.357 秒,另有 16 个跳过 | Test 步骤 366 秒 | -| Integration Scenarios | 5:16 | 15 个 bin 内单测及 11 个 E2E 用例,约 8 秒 | Test 步骤 325 秒 | -| Data | 未进一步拆分 | 保留数据库相关保障 | Test 步骤 55 秒,job 88 秒 | - -另一轮 `34153166516` 的 Gateway lib 编译 6:22、执行 390.039 秒,bins 编译 3:05、执行 0.329 秒。运行环境和缓存差异明显,不能只用最快一轮估算收益。 - -### 2.2 源码与磁盘 - -源码只统计 Git 跟踪文件,行数为物理行,包含注释和测试,不等于生产代码行数。 - -| 项目 | 规模 | -| --- | --- | -| Cargo workspace | 42 个 package | -| Rust 源码 | 1,874 个文件,1,114,527 行 | -| Gateway package 的 Rust 文件 | 1,176 个文件,679,538 行,约占全部 Rust 行数 61% | -| 以 tests/test/testkit 等命名识别的 Rust 测试及支持内容 | 214,086 行;未加上大量内联 `#[cfg(test)]` 模块 | -| Gateway 架构守卫测试 | 13 个文件,14,918 行,208 个 `#[test]` | -| 前端测试 | 208 个文件,31,945 行;Nightly 实跑 1,486 个用例 | -| 本地 `target/debug/incremental/` | 约 116GB | -| 本地 `target/debug/deps/` | 约 49GB | -| 本地 `frontend/node_modules/` / `frontend/dist/` | 约 311MB / 8MB | -| 本地历史 `htmlcov/` / `logs/` | 约 75MB / 121MB,均被 Git 忽略 | - -## 三、优先处理:不降低保障的浪费 - -### P1-1:解除 Tunnel 测试对完整 Gateway 的反向依赖 - -**证据:** `apps/aether-tunnel/Cargo.toml:48` 将带 `testkit` 的 Gateway 列为 dev-dependency。`.github/workflows/rust-ci.yml:208` 和 `.github/workflows/rust-ci.yml:394` 虽然排除 Gateway 目标,但 Rest 的真实日志仍出现编译 `aether-gateway`。Gateway、Rest、Integration 因此在独立 runner 中重复付出重型构建成本。 - -**建议:** 将 Tunnel 中需要完整 Gateway 的端到端场景迁到独立集成测试目标;Tunnel 的协议、状态机、配置等单测只依赖轻量契约和测试支持。迁移之后比较测试清单,确保没有丢失端到端场景。 - -**验收:** 普通 Rest 单测及 Clippy 的依赖闭包不再包含 Gateway;Tunnel 跨端集成场景仍在专门任务执行。此项主要降低累计 runner 工作量,是否缩短总时长取决于 Gateway 关键路径是否也得到优化。 - -### P1-2:发布只编译真正发布的二进制 - -**证据:** Cargo metadata 显示 Gateway 有 4 个 bin:服务主程序、backup-restore、两个 WebSocket probe。`.github/workflows/release.yml:227`、`.github/workflows/release.yml:229` 和 `.github/workflows/nightly.yml:296` 未选择具体 bin,但 `.github/workflows/release.yml:236` 只上传主程序。 - -2026-09-07 的正式发布中,macOS Intel job 耗时 31:24,编译/链接日志耗时 28:52;Nightly 对应 job 耗时 33:45。发布慢不能全算在测试头上。 - -**建议:** 正式发布与 Nightly 的 `cargo build` / `cross build` 添加 `--bin aether-gateway`;probe 和其他运维程序保留独立检查、测试或按需打包入口。保留当前 release 优化策略,先测减少目标的收益,再讨论 LTO 调整。 - -**边界:** 没有逐 bin 链接计时,不能声称这会让整个发布缩短四分之三。 - -### P1-3:取消空 doctest 构建,合并重复的驱动检查 - -- `.github/workflows/nightly.yml:100` 的 workspace doctest 步骤耗时 252 秒,41 个库合计 0 个用例。建议对确实无 doctest、且不计划承载文档示例的库显式管理 `doctest`,或通过独立清单检查是否存在可执行文档示例后再调度。未来新增示例必须能重新纳入检查,不能永久盲目跳过全部文档测试。 -- `crates/aether-data/runtime/Cargo.toml:10` 中 `default = ["postgres"]`,`all-drivers = ["postgres"]`,源码没有单独依赖 `all-drivers` 的条件分支。`.github/workflows/rust-ci.yml:334` 的两个 feature job 实际没有覆盖两个不同数据库驱动。 -- `.github/workflows/rust-ci.yml:394` 的 Rest 已执行 Postgres adapter 的测试,`.github/workflows/rust-ci.yml:403` 又运行一次。若保留独立 adapter job,应从 Rest 排除它;否则直接以 Rest 承担这份覆盖。 - -**收益口径:** 在样本中,去掉空 doctest 可省约 4:12 的该 job 时间;feature 与 adapter 重复工作为几十秒量级。它们多数不在普通 CI 关键路径上,不应当作主 CI 总时长的等额收益。 - -### P1-4:区分集成测试与压测工具的构建 - -**证据:** `crates/aether-testing/integration/Cargo.toml:1` 所属 package 自动发现 14 个场景 bin;其中 11 个没有测试函数。`.github/workflows/rust-ci.yml:467` 对整个 package 执行 `--bins --tests`,耗时 5:25,实际测试约 8 秒。 - -这里并没有执行那些压测程序的 `main()` 来验证容量、恢复时间或性能;空测试 harness 的成功不能视为压测成功。 - -**建议:** 将 E2E 测试与 benchmark/probe 工具分开;把三个工具内的 15 个单测迁入适合的库或独立目标。工具源码继续接受 Clippy/check,真实压测由手动或计划任务运行。必要时使用 `required-features` 门控工具 bin。 - -**注意:** 仅给 bin 添加 `test = false` 不足以阻止所有额外构建;Cargo 构建 integration test 时还可能自动构建同 package 的普通 bin。应从目标和 package 边界解决,而不是仅换命令拼写。 - -### P1-5:重做按变更类型路由,同时补覆盖缺口 - -**证据:** `.github/workflows/rust-ci.yml:9` 将 README、安装脚本、Compose 与 Rust 源码共同触发整条 Rust 流水线;job 内没有进一步区分。当前五份工作流没有 frontend PR 工作流;前端完整检查在 Nightly 执行。 - -另一个现存缺口:`apps/aether-gateway/tests/admin_unsigned_identity_headers.rs:16` 的普通 integration test 不在 Gateway 的 `--lib`、`--bins` 两条 nextest 命令内;独立 Integration Scenarios job 选择的是另一个 package。Nightly 的 `check --all-targets` 只检查编译,不会替代执行此安全用例。 - -**建议:** - -1. 安装脚本、Compose、发布工作流改动优先运行对应安全 fixtures;纯 README 文档修改不必编译完整 Gateway。 -2. Rust 改动执行相关测试;Cargo、工具链、公共契约和测试基础设施变更应保守扩大到完整检查。 -3. 前端改动运行自己的类型检查和测试,不必等待 Nightly。 -4. 显式加入 Gateway integration test,包括上述身份头安全用例。 -5. 保留稳定的最终 `check` 门禁,并验证预期跳过的 job。不要让路径过滤造成 required check 永久 pending,或把失败当成允许跳过。 - -工具链触发项还应补查 `rust-toolchain.toml`、`.cargo/**`;这些文件目前不在该工作流的路径列表中。 - -## 四、关键路径:Gateway 测试要减“重量”而非减断言 - -### P1-6:改造昂贵夹具与不必要的全应用初始化 - -成功样本中最慢的用例包括: - -| 用例 | 时间 | 代码位置 | -| --- | --- | --- | -| 跳过大量 blocked account 的扫描预算 | 22.668 秒 | `apps/aether-gateway/src/dispatch/pool_scheduler.rs:3940` | -| v1 备份兼容及历史密钥尝试 | 14.324 秒 | `apps/aether-gateway/src/backup/executor.rs:1223` | -| 跳过大量 exhausted account | 8.481 秒 | `apps/aether-gateway/src/dispatch/pool_scheduler.rs:3859` | -| 大池 LRU 和动态跳过 | 8.147 秒 | `apps/aether-gateway/src/dispatch/pool_scheduler.rs:4424` | - -池调度测试会构造 1,700 个账号,并在 `apps/aether-gateway/src/dispatch/pool_scheduler.rs:4868` 为每个账号调用真实凭据封装。可以将扫描预算、分页、跳过逻辑用轻量 repository/credential fixture 验证,另保留少量真实加解密联调用例和大规模边界场景;不要简单把大池规模缩小到失去原来的回归条件。 - -`crates/aether-crypto/src/python_fernet.rs:302` 的历史密钥派生包含进程内缓存及 100,000 次 PBKDF2。真实 nextest 用例是分进程执行的,跨用例不能指望共享这份缓存。是否构成主要开销仍需针对性计时;可为不测试历史派生逻辑的夹具选择合法的固定测试密钥或预制密文,历史兼容和真实密码学用例必须保留生产强度。 - -**不要做:** 为通过 CI 下调生产加密迭代数、删掉备份兼容测试、跨测试共享可变 AppState、将关键安全测试统一 ignore。 - -### P1-7:把巨大单一测试目标拆成真正独立的边界 - -`apps/aether-gateway/src/lib.rs:234` 将广泛的内部测试树纳入同一个 lib test binary。单纯把大文件拆成几个 `mod` 文件,不会使它们成为独立 Cargo 编译单元。 - -建议优先迁出无需访问私有业务状态的架构守卫测试,再逐步将调度策略、协议转换、计费纯函数测试迁到所属 crate;HTTP 行为与跨模块场景放到有明确支持 API 的 integration target。避免为了迁移测试而把所有内部类型公开。 - -架构守卫现有 208 个测试、约 1.49 万行,很多是源码字符串和依赖规则检查。例如 `apps/aether-gateway/src/tests/architecture/workspace_tiers.rs:3` 按 manifest 字符串判断依赖边界。它们应继续存在,但可进入不依赖 Gateway 的小工具/测试目标;依赖规则优先检查 Cargo metadata,行为正确性继续由行为测试负责。 - -若采用 nextest 分片,应先复用一次构建产物,再分发执行;直接给 N 个 runner 各自重新编译巨型 Gateway 会放大成本。先衡量编译与执行占比,再确定是否分片。 - -**特别注意:** 仅将 `--lib` 与 `--bins` 合为一条命令,并不消除普通库与 `cfg(test)` 测试库的两种构建。不能把样本中 2:27 的 bins 编译时间直接记作可全部省掉。 - -### P1-8:缓存按实际构建方式组织 - -所有 Rust job 使用同一个 `shared-key`。实测 Gateway、Rest、Integration 恢复了同一份约 380MB 的缓存;日志显示 `cache-workspace-crates: false`。但 Gateway 的 mold `RUSTFLAGS` 只存在于测试 step,见 `.github/workflows/rust-ci.yml:265`,其余任务又使用不同的 Clippy/check/test 和 feature 组合。 - -Gateway 样本的 Rust sccache 命中率达到 95.48%,仍花了数分钟构建,并存在 76 次标记为 `crate-type` 的不可缓存调用。这说明“再装一个缓存”不是根治;同时也不能据此断言缓存无效。 - -建议把影响缓存选择的环境提前到 job 级,按 lint/check 与 test、target、toolchain、必要 feature 区分缓存用途;相同构建尽量统一。先测恢复、保存、不可缓存调用与编译时间,不要给每个细碎目标无限新建 cache key,也不要直接缓存完整的巨型 `target/`。 - -现有 nextest、sccache、Gateway mold 和 CI 的关闭 debug 信息配置已经到位,不列为“尚未实施”的建议。 - -## 五、源码和依赖的长期瘦身 - -### P2-1:继续完成已有模块边界,而不是继续增加空壳 crate - -Gateway 仍承载约 67.95 万行 Rust。部分现有边界很薄:Gateway execution crate 82 行、control crate 100 行、provider core 91 行、usage core 110 行,而核心实现仍留在应用中。 - -值得分批治理的集中点: - -| 文件 | 物理行数 | 建议拆分依据 | -| --- | --- | --- | -| `apps/aether-gateway/src/execution_runtime/stream/execution.rs:1` | 15,088 | 流状态机、传输适配、计费收尾、对应测试 | -| `crates/aether-data/adapters/postgres/src/usage/mod.rs:1` | 13,833 | 写入、查询、统计聚合、审计存储 | -| `crates/aether-usage/runtime/src/runtime.rs:1` | 12,809 | 状态推进、结算策略、持久化适配、测试 | -| `apps/aether-gateway/src/handlers/admin/request/system/import.rs:1` | 9,490 | 导入校验、版本兼容、执行与回滚 | - -这些数字包含内联测试,不是生产实现行数。目标应是缩小依赖闭包、变更影响面和测试目标,而非追求拆出更多文件。 - -`apps/aether-gateway/src/state/app.rs:376` 的 AppState 有 91 个字段,其中 28 个字段名包含 cache;`crates/aether-data/contracts/src/repository/usage/types.rs:1984` 的 UpsertUsageRecord 有 67 个字段。这反映了测试构造和模块依赖面较宽,但不能据此直接判定运行时占用过大。可以引入按职责的窄上下文和统一 fixture builder,避免每个测试复制完整对象。 - -### P2-2:从基础契约中剥离重型格式实现 - -`crates/aether-data/contracts/Cargo.toml:10` 依赖整个 `aether-ai-formats`;后者约 8.23 万行 Rust。contracts 中实际使用包括格式权限、别名与少量 usage 元数据策略,见 `crates/aether-data/contracts/src/repository/auth.rs:295`。 - -建议把稳定的格式标识、权限和小型元数据契约下沉到现有基础契约层,完整 request/response/stream 转换留在 formats。避免为了一个格式权限判断,让数据库契约持续依赖整个转换实现。这比任意合并 crate 更有价值。 - -### P2-3:依赖体积优化必须保留兼容能力 - -`cargo tree --offline --locked` 确认 Gateway 同时包含: - -- 主请求链路的 reqwest 0.12 与 `object_store` 引入的 reqwest 0.13。 -- rustls 的 ring 和 aws-lc 路径,以及 wreq 的 boring2 路径。 - -这些是进一步分析构建和二进制体积的候选,不是已证实可直接删除的依赖。`aws-lc-rs` 还有直接密码学用途,wreq 承担专门传输能力;移除前必须核查调用和握手、指纹、代理兼容测试。 - -先获取 release 的 Cargo timings 和二进制符号/section 体积,再评估版本统一或可选能力 profile;不要仅根据依赖名字或锁文件重复条目盲目替换。 - -## 六、前端测试与构建 - -### P1/P2:测试环境按需要加载 - -Nightly 前端测试实测 106.01 秒,208 个文件、1,486 个用例全部通过。Vitest 报告 environment 146.40 秒、import 51.29 秒、tests 72.78 秒;这些分项含并行累计时间,不能相加当作墙钟时间。 - -`frontend/vitest.config.ts:10` 为全部测试使用 jsdom;静态扫描有 126/208 个测试文件未直接出现常见 DOM 操作标记,但这并不证明它们的传递依赖不需要 DOM。`frontend/src/tests/vitest.setup.ts:61` 还在每个用例前加载 i18n 并重设语言。 - -建议通过独立 Vitest project 或显式环境标记,将已确认的纯函数/解析器/数据转换测试放到 node 环境,DOM 组件保留 jsdom;按测试类别拆分 setup,不要全局取消隔离。先迁一组、验证测试数与结果,再扩展。 - -### P1:同一 web 项目重复构建 - -`.github/workflows/release.yml:84` 和 `.github/workflows/deploy-pages.yml:58` 已单独构建 VSCodex web;随后 frontend 的 `prebuild` 又调用 `frontend/scripts/sync-vscodex.mjs:64` 无条件重建一次。 - -建议分开“安装/构建嵌入 web”与“复制已构建产物”,在同一个任务内只构建一次,消费明确来源的 artifact。不要仅通过 `dist` 存在就认定源码和产物一致。Nightly 前端这里没有相同的预先重复 build,不应错误地宣称所有流水线都重复。 - -### P2:低风险依赖清理候选 - -当前前端源码未发现 `three` 的模块导入,但 package 声明了 `three` 和 `@types/three`;本地二者合计约 36MB。确认无动态/外部消费者后可移除并更新锁文件,验证 type-check、测试和构建。 - -这主要减少安装和维护成本;不能保证生产 bundle 同样减少 36MB。现有 chart、pinyin、Stripe 均发现使用,不能一并判作无用依赖。 - -MarkdownViewer 使用完整 `highlight.js`,而 CodeHighlight 已按语言导入,可统一按需高亮策略;现有前端产物约 8MB,优先级低于 Rust 重复构建和测试初始化。 - -## 七、本地构建产物治理 - -当前最值得清理的是 116GB 的 `target/debug/incremental/`,其次是 49GB 的 `target/debug/deps/`。全量 `cargo clean` 虽能回收空间,也会迫使下次重建全部依赖。 - -建议先确认没有进行中的 Cargo/rustc 任务,对长期未使用的增量会话、历史目标产物做定期清理;稳定本地 profile、工具链和 `RUSTFLAGS`,避免频繁产生不同构建组合。保留仍在使用的依赖缓存,不要每次构建前清空 target。 - -`htmlcov/` 属于历史 Python 覆盖率产物,可在确认不再需要后清理,但仅几十 MB,不是主要收益。不要误删仍有效的 Python 安装/Compose 安全测试,也不要把日志、备份或数据目录当构建缓存删除。 - -生产 Dockerfile 已使用预构建二进制、前端产物和 distroless runtime,并通过 `.dockerignore` 排除 target、node_modules 等开发内容;不建议把“换更小基础镜像”列为当前第一优先级。生产镜像实际体积还需单独测量。 - -## 八、建议实施顺序与验收 - -| 批次 | 改造 | 验收标准 | -| --- | --- | --- | -| A:小改动去空转 | 发布指定主 bin;管理空 doctest;消除重复 adapter 与等价 feature 任务;嵌入 web 只构建一次 | 保留现有有效用例与工件;对照任务耗时和测试清单 | -| B:加快反馈并补漏 | 变更路由、稳定 gate、前端 PR 检查、Gateway integration 安全用例 | 纯文档/脚本不编译全栈;公共变更仍完整检查;安全用例实际执行 | -| C:解除编译耦合 | Tunnel 跨端测试迁移;工具与 E2E 分离;架构守卫轻量化 | Rest 依赖闭包无 Gateway;无用 bin 不参与 E2E 构建;守卫持续有效 | -| D:减少测试初始化 | 重型夹具拆层;纯前端测试切 node;窄上下文和统一 builder | 慢用例时间降低,测试数与断言目的不减少,无新增污染或 flaky | -| E:结构与依赖治理 | 真实职责迁入现有 crate;基础格式契约下沉;审慎统一依赖 | 普通修改触发的重编译面缩小,性能和兼容性基线不回退 | - -每批先使用相同 SHA、runner 类型、toolchain 和 feature 集合做对照,至少区分冷缓存/热缓存与 job 执行/流水线总耗时。持续保存编译 timings、nextest 测试清单和结果、慢测试列表、缓存统计、frontend environment 时间、发布工件体积。 - -首要结果指标:普通 PR 更快得到正确反馈、累计重复构建下降、测试保障不退化。不要把删除测试数量、增大并发数或缩短单个非关键任务当作最终目标。 - -## 九、复查入口 - -本次没有重新运行全量构建或全量测试,使用了真实 CI 日志、离线 Cargo metadata/tree 和只读源码统计。临时 API JSON、job 日志与依赖树保存在本机 `/tmp/aether-slim-audit/`,未纳入 Git;临时目录可能被系统清理,运行 ID 可用于再次取证。 - -可重复的只读命令: - -```sh -cargo metadata --offline --locked --no-deps --format-version 1 -cargo tree --offline --locked -p aether-gateway -e normal -d -gh api 'repos/fawney19/Aether/actions/runs/34174603131/jobs?per_page=100' -gh api 'repos/fawney19/Aether/actions/jobs/101901417322/logs' -gh api 'repos/fawney19/Aether/actions/jobs/101869558414/logs' -du -h -d 1 target/debug -``` - -Cargo 关于默认构建目标及 integration test 自动构建 bin 的语义,另对照本机 Rust 1.95.0 随附的 Cargo `cargo-build`、`cargo-test`、`cargo-targets` 官方文档。并行测试时间、缓存命中率和源码体积均按各自定义解释,未将其混用为生产性能结论。 diff --git a/docs/operations/tls-fingerprint-capture.md b/docs/operations/tls-fingerprint-capture.md deleted file mode 100644 index 0159ead29..000000000 --- a/docs/operations/tls-fingerprint-capture.md +++ /dev/null @@ -1,94 +0,0 @@ -# TLS Fingerprint Capture - -Aether stores per-request TLS capture under `usage.request_metadata.tls_fingerprint`. - -```json -{ - "tls_fingerprint": { - "incoming": { - "source": "forwarded_header", - "ja3": "...", - "ja3_hash": "...", - "ja4": "...", - "protocol": "TLSv1.3", - "cipher": "TLS_AES_128_GCM_SHA256", - "sni": "api.example.com", - "alpn": "h2" - }, - "outgoing": { - "source": "aether_transport_config", - "observed": false, - "transport_path": "direct", - "backend": "reqwest_rustls", - "http_mode": "auto", - "tls_stack": "rustls", - "tls_versions_offered": ["TLS1.3", "TLS1.2"], - "alpn_offered": ["h2", "http/1.1"] - } - } -} -``` - -`incoming` is the client-to-Aether TLS fingerprint. It can be populated by Aether native TLS capture in direct deployments or by trusted reverse-proxy headers when TLS terminates before Aether. - -`outgoing` is the Aether-to-provider TLS transport record. The current gateway records the exact transport configuration it controls. It sets `observed: false` because reqwest/rustls does not expose the emitted ClientHello bytes on the direct path. A future connector-level ClientHello capture or probe result can reuse the same object with `observed: true` plus `ja3`, `ja3_hash`, and `ja4`. - -## Nginx TLS Termination - -When nginx terminates HTTPS and proxies HTTP to Aether, Aether cannot see the original ClientHello. Configure nginx to forward the TLS fields it can observe: - -```nginx -server { - listen 443 ssl http2; - server_name api.example.com; - - ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_set_header X-Aether-TLS-Source nginx; - proxy_set_header X-Aether-TLS-Protocol $ssl_protocol; - proxy_set_header X-Aether-TLS-Cipher $ssl_cipher; - proxy_set_header X-Aether-TLS-SNI $ssl_server_name; - } -} -``` - -Stock nginx does not provide JA3/JA4 variables. The forwarded record is still useful, but it is not a complete TLS fingerprint. To forward JA3/JA4 through nginx, use an nginx build/module or edge layer that computes them and set: - -```nginx -proxy_set_header X-Aether-TLS-JA3 $ja3; -proxy_set_header X-Aether-TLS-JA3-Hash $ja3_hash; -proxy_set_header X-Aether-TLS-JA4 $ja4; -``` - -Only accept these headers from trusted infrastructure. Do not expose Aether directly to public clients while also trusting client-supplied `X-Aether-TLS-*` headers. - -## Nginx TCP Passthrough - -If Aether terminates TLS itself, nginx can pass TCP through without decrypting: - -```nginx -stream { - map $ssl_preread_server_name $aether_backend { - api.example.com 127.0.0.1:3443; - default 127.0.0.1:3443; - } - - server { - listen 443; - proxy_pass $aether_backend; - ssl_preread on; - } -} -``` - -In this mode nginx cannot inject HTTP headers because it never sees HTTP. Aether native TLS capture is responsible for populating `tls_fingerprint.incoming`. diff --git a/docs/operations/usage-body-viewing.md b/docs/operations/usage-body-viewing.md deleted file mode 100644 index c481971fe..000000000 --- a/docs/operations/usage-body-viewing.md +++ /dev/null @@ -1,65 +0,0 @@ -# 请求正文查看与性能边界 - -## 按需读取 - -请求详情的轻量读取仍使用 `GET /api/admin/usage/{id}?include_bodies=false`,返回正文可用性与记录概要,不解析正文。 - -查看正文时,管理界面使用 `GET /api/admin/usage/{id}?include_bodies=true&body_field={field}&body_format=raw`。`body_field` 仅允许以下值: - -- `request_body`:客户端请求体。 -- `provider_request_body`:提供商请求体。 -- `response_body`:提供商响应体。 -- `client_response_body`:客户端响应体。 - -网页正文读取不再经过服务器 JSON 解码链路。数据库中的 `payload_gzip` 原样返回,历史压缩列同样直传;历史内联 JSON 返回 JSON 字节。接口仍经过管理权限校验、正文捕获状态检查、引用归属校验及审计,不加载用户名称和提供商名称等无关详情。 - -二进制响应的 `X-Aether-Body-Encoding` 为 `gzip` 或 `json`,另含 `X-Aether-Usage-Id`、`X-Aether-Body-Field`,前端验证记录与字段匹配。使用 `application/octet-stream`、`Content-Encoding: identity`,避免 HTTP 中间件重复压缩或浏览器提前解压;`Cache-Control: no-store, no-transform` 避免缓存敏感正文及代理改写。跨域允许凭证时显式暴露上述协议头。错误通过 HTTP 状态及 `X-Aether-Body-Error` 返回,不要求主线程解析二进制错误响应。 - -未指定 `body_format=raw` 的服务端 JSON 接口保持原有行为,但网页不再调用它加载正文;前端详情 API 默认也只取概要。非法字段、空字段、raw 模式缺少字段或同时设置 `include_bodies=false` 返回 HTTP 400。 - -前端按请求和正文字段保留 Worker 句柄,最多缓存两份已解析正文,并按解压字节总量 64 MiB 预算淘汰最久未访问的 Worker。该预算不是浏览器进程内存上限:解析对象、字符串及加载中的数据仍有额外开销。切换正文来源或标签会取消未完成的下载与解压;关闭抽屉、切换记录或组件卸载会终止全部 Worker。迟到结果不显示、不入缓存。请求从进行中变为完成或失败时,正文缓存失效,并重新读取当前选中的正文。 - -## 渲染与资源控制 - -- 仅挂载当前标签的内容,不再后台渲染隐藏标签。 -- JSON 与对话均连续虚拟滚动,不需要点击上一页或下一页。接近底部时自动读取后续内容,向上滚动可重新查看先前内容。 -- 折叠节点不遍历其子节点;点击括号展开节点时完整展开该子树,无需逐层点击。JSON 内部按 50 个显示片段批量读取,视口与预读区域最多挂载 4 批(200 个片段)。离屏内容用高度占位,缓存仅保留视口附近最多 6 批,不随滚动积累 DOM 或正文副本。 -- JSON 不再有“显示更多”或“继续显示”按钮:长字符串和长键名在 Worker 中分段转义,随滚动自动显示全部字符。纯文本及非 JSON 响应同样自动衔接完整内容。分段保持 Unicode 字符完整,续段不重复显示 JSON 行号。 -- 虚拟显示范围不改变正文长度;复制按钮仍复制完整内容。JSON 顺序读取复用遍历游标,避免每次滚动都从正文起点重新遍历。 -- 压缩数据以 transferable ArrayBuffer 交给 Worker;解压、UTF-8 解码、JSON 解析、JSON 遍历及对话解析均在 Worker 中进行,完整对象不返回页面主线程。 -- 对话内部按每批最多 10 个顶层块预览并自动衔接,限制嵌套块和文本传输量,长内容明确提示并支持增加预览。完整复制在用户点击后由 Worker 生成,不受虚拟显示范围影响。 -- Worker 解压时逐块检查 64 MiB 上限,损坏 gzip、无效 UTF-8 和无效 JSON 给出明确错误。后台任务 30 秒超时会终止 Worker;不支持 Worker 或原生 gzip 解压的浏览器提示升级,不回退到主线程解析。 -- 服务端最多 4 个后台解码任务的保护仍用于复制 cURL、请求重放等实际内部调用,不是网页解压的兼容回退。存储读取实现复用,不维护两套数据库读取逻辑。 -- 数据库读取先按存储字节过滤:压缩正文最大 65 MiB,未压缩正文最大 64 MiB,超限不将完整载荷读入网关;浏览器还会独立校验解压后的大小。 - -## 错误定位 - -二进制接口的 `X-Aether-Body-Error` 和 JSON 接口的 `body_load_error_codes` 使用以下存储错误码;浏览器解压失败也映射为相同的明确提示: - -| 错误码 | 含义 | -| --- | --- | -| `too_large` | 解压后正文超过 64 MiB 的安全读取上限。 | -| `decode_failed` | 正文解压或 JSON 解析失败。 | -| `missing` | 记录标记正文可用,但未能解析到实际存储内容。 | -| `storage_unavailable` | 其他存储读取错误;内部连接信息不会返回给页面。 | - -前端分别显示请求超时、网络失败、HTTP 错误及上述存储错误。`too_large` 和 `decode_failed` 不提供无意义的重复重试;其他错误可手动重试。正文请求仍沿用现有 API 超时配置。 - -完整记录模式与已有记录不做静默截断;64 MiB 解压安全上限没有放宽。因此,超出上限的历史正文仍不能在线预览,但页面会明确说明限制,不再仅显示笼统的加载失败。 - -前后端需要一同更新。前端收到缺少二进制协议头、记录不匹配或字段不匹配的响应时会拒绝显示,并提示检查前后端版本;不会自动退回耗资源的完整 JSON 正文接口。构建前端时必须同时发布生成的 Worker JavaScript 资源。 - -## 针对性验证 - -```sh -cd frontend -npm run test:run -- src/features/usage src/api/__tests__/dashboard-body-loading.spec.ts -npm run type-check -``` - -```sh -cargo test -p aether-data-postgres usage_body_decode --lib --offline -cargo test -p aether-gateway admin_usage_detail --lib --offline -cargo test -p aether-gateway raw_body --lib --offline -cargo test -p aether-gateway body_load_errors_expose_safe_codes --lib --offline -``` diff --git a/docs/operations/usage-header-capture.md b/docs/operations/usage-header-capture.md deleted file mode 100644 index b3d4e1a65..000000000 --- a/docs/operations/usage-header-capture.md +++ /dev/null @@ -1,17 +0,0 @@ -# Usage header capture - -Usage HTTP captures preserve original header values for client requests, -provider requests, provider responses, and client responses. Header maps are -validated as JSON objects but their values are not redacted. Capture settings -and existing access controls still apply. - -These records can contain credentials such as Authorization, API keys, and -cookies, as well as session identifiers and client metadata. Restrict access -to usage details, database records, exports, and backups accordingly. - -Previously stored `[redacted]` values cannot be recovered. Original values -are available only for new captures after deploying this change. - -The request detail drawer reads these values directly from the administrator -usage-detail API, including when bodies are not loaded. No frontend setting -can recover header values that were already replaced during capture. diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index 50f4bc99b..f5f056118 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -192,4 +192,59 @@ describe('apiClient auth state change event', () => { rawClient.defaults.adapter = previousAdapter } }) + + it('authenticates user and admin health while public status stays anonymous', async () => { + const rawClient = apiClient['client'] + const previousAdapter = rawClient.defaults.adapter + const requests: InternalAxiosRequestConfig[] = [] + + rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { + requests.push(config) + return { data: {}, status: 200, statusText: 'OK', headers: {}, config } + }) as AxiosAdapter + + try { + apiClient.setToken('health-access-token') + await apiClient.get('/api/users/me/health/v2/summary') + await apiClient.get('/api/admin/endpoints/health/v2/objects') + await apiClient.get('/api/public/health/v2/summary') + await apiClient.get('/_gateway/health') + + expect(requests.map(request => request.headers.Authorization)).toEqual([ + 'Bearer health-access-token', + 'Bearer health-access-token', + undefined, + undefined, + ]) + expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy() + } finally { + rawClient.defaults.adapter = previousAdapter + } + }) + + it('keeps publication and protected JSON routes authenticated despite public-looking names or query strings', async () => { + const rawClient = apiClient['client'] + const previousAdapter = rawClient.defaults.adapter + const requests: InternalAxiosRequestConfig[] = [] + rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { + requests.push(config) + return { data: {}, status: 200, statusText: 'OK', headers: {}, config } + }) as AxiosAdapter + try { + apiClient.setToken('publication-access-token') + await apiClient.get('/api/admin/endpoints/health/v2/publication') + await apiClient.put('/api/admin/endpoints/health/v2/publication', { enabled: false, objects: [] }) + await apiClient.get('/api/admin/usage/records?search=/public/health.json') + await apiClient.get('/api/admin/reports.json') + await apiClient.get('/api/publicity') + await apiClient.get('/api/public/health/v2/summary?window=1h') + expect(requests.map(request => request.headers.Authorization)).toEqual([ + 'Bearer publication-access-token', 'Bearer publication-access-token', + 'Bearer publication-access-token', 'Bearer publication-access-token', + 'Bearer publication-access-token', undefined, + ]) + } finally { + rawClient.defaults.adapter = previousAdapter + } + }) }) diff --git a/frontend/src/api/__tests__/overview-dashboard.spec.ts b/frontend/src/api/__tests__/overview-dashboard.spec.ts new file mode 100644 index 000000000..f4b6003f2 --- /dev/null +++ b/frontend/src/api/__tests__/overview-dashboard.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() })) + +vi.mock('@/api/client', () => ({ + default: { get: getMock }, +})) + +import { overviewApi, type OverviewQuery } from '@/api/overview' + +describe('overview dashboard API contract', () => { + beforeEach(() => { + getMock.mockReset() + }) + + it('loads the compact flat dashboard snapshot with timezone and cancellation', async () => { + const signal = new AbortController().signal + const response = { stats_since: '2026-09-19T00:00:00Z', today: { request_count: 3 }, total: { request_count: 42 } } + getMock.mockResolvedValueOnce({ data: response }) + expect(await overviewApi.dashboardSummary('Asia/Shanghai', signal)).toBe(response) + expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/summary', { params: { timezone: 'Asia/Shanghai' }, signal }) + }) + + it.each([true, false, null])('loads today and lifetime totals with history_complete=%s using only the timezone', async (historyComplete) => { + const signal = new AbortController().signal + const response = { + today: { data: { request_count: 2 } }, + total: { data: { request_count: 42 } }, + history_complete: historyComplete, + } + getMock.mockResolvedValueOnce({ data: response }) + + const result = await overviewApi.dashboard('America/New_York', signal) + + expect(getMock).toHaveBeenCalledTimes(1) + expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard', { + params: { timezone: 'America/New_York' }, + signal, + }) + expect(result).toBe(response) + }) + + it.each([{ status: 'pending' }, { status: 'ready', total: { data: { request_count: 42 } }, history_complete: null, stale: true }])( + 'loads independently computed lifetime totals: $status', async (response) => { + const signal = new AbortController().signal + getMock.mockResolvedValueOnce({ data: response }) + + expect(await overviewApi.dashboardTotal('Asia/Shanghai', signal)).toBe(response) + expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/total', { + params: { timezone: 'Asia/Shanghai' }, signal, + }) + }, + ) + + it('loads full-site daily charts without leaking filters, pagination, or hourly granularity', async () => { + const signal = new AbortController().signal + const query: OverviewQuery = { + from: '2026-03-08T05:12:34.000Z', + to: '2026-03-09T04:00:00.000Z', + timezone: 'America/New_York', + user_id: 'employee-1', + api_key_id: 'key-1', + credential_owner_id: 'owner-1', + attribution_kind: 'employee', + provider_id: 'provider-1', + model: 'model-1', + request_type: 'chat', + api_format: 'openai', + endpoint_kind: 'chat', + is_stream: false, + has_format_conversion: false, + slow_threshold_ms: 1000, + status: 'success', + search: 'employee', + account_status: 'active', + usage_status: 'active', + amount_basis: 'rated', + group_by: 'provider', + sort: 'request_count', + order: 'asc', + granularity: 'hour', + limit: 1, + offset: 10, + } + const response = { + meta: { read_revision: 'dashboard-chart-revision' }, + data: { + summary: { request_count: 5 }, + series: [{ bucket_start: '2026-03-08T05:00:00.000Z' }], + models: [{ id: 'model-1', bucket_start: '2026-03-08T05:00:00.000Z' }], + providers: [{ id: 'provider-1' }, { id: 'provider-2' }, { id: null }], + }, + } + getMock.mockResolvedValueOnce({ data: response }) + + const result = await overviewApi.dashboardCharts(query, signal) + + expect(getMock).toHaveBeenCalledTimes(1) + expect(getMock).toHaveBeenCalledWith('/api/admin/overview/dashboard/charts', { + params: { + from: query.from, + to: query.to, + timezone: query.timezone, + granularity: 'day', + }, + signal, + }) + expect(result).toBe(response) + }) +}) diff --git a/frontend/src/api/admin-wallets.ts b/frontend/src/api/admin-wallets.ts index e50d235a2..5f124bb49 100644 --- a/frontend/src/api/admin-wallets.ts +++ b/frontend/src/api/admin-wallets.ts @@ -94,12 +94,13 @@ export interface RefundCompleteRequest { export const adminWalletApi = { async listWallets(params?: { + user_id?: string status?: string owner_type?: 'user' | 'api_key' limit?: number offset?: number - }): Promise { - const response = await apiClient.get('/api/admin/wallets', { params }) + }, signal?: AbortSignal): Promise { + const response = await apiClient.get('/api/admin/wallets', { params, signal }) return response.data }, @@ -183,11 +184,12 @@ export const adminWalletApi = { async getWalletTransactions( walletId: string, - params?: { limit?: number; offset?: number } + params?: { limit?: number; offset?: number }, + signal?: AbortSignal, ): Promise { const response = await apiClient.get( `/api/admin/wallets/${walletId}/transactions`, - { params } + { params, signal } ) return response.data }, diff --git a/frontend/src/api/announcements.ts b/frontend/src/api/announcements.ts index d4cda6db7..432994123 100644 --- a/frontend/src/api/announcements.ts +++ b/frontend/src/api/announcements.ts @@ -50,6 +50,15 @@ export interface UpdateAnnouncementRequest { } export const announcementApi = { + async getUserAnnouncements(params?: { + unread_only?: boolean + limit?: number + offset?: number + }): Promise { + const response = await apiClient.get('/api/announcements/users/me', { params }) + return response.data + }, + // 获取公告列表 async getAnnouncements(params?: { active_only?: boolean diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d50733e35..a7741831b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -39,15 +39,19 @@ function loadMockRuntime(): Promise { /** * 判断请求是否为公共端点 */ +function requestPath(url?: string): string { + if (!url) return '' + try { return new URL(url, 'http://aether.local').pathname } catch { return '' } +} + function isPublicEndpoint(url?: string, method?: string): boolean { - if (!url) return false + const path = requestPath(url) + const isHealthCheck = ['/health', '/v1/health', '/_gateway/health'].includes(path) && + method?.toLowerCase() === 'get' - const isHealthCheck = url.includes('/health') && - method?.toLowerCase() === 'get' && - !url.includes('/api/admin') - - return url.includes('/public') || - url.includes('.json') || + return path === '/api/public' || path.startsWith('/api/public/') || + path === '/public' || path.startsWith('/public/') || + (!path.startsWith('/api/') && path.endsWith('.json')) || isHealthCheck } @@ -55,12 +59,11 @@ function isPublicEndpoint(url?: string, method?: string): boolean { * 判断是否为认证相关请求 */ function isAuthRequest(url?: string): boolean { - return url?.includes('/auth/login') || url?.includes('/auth/refresh') || url?.includes('/auth/logout') || false + return ['/api/auth/login', '/api/auth/refresh', '/api/auth/logout'].includes(requestPath(url)) } function isProtectedOperationalEndpoint(url?: string): boolean { - if (!url) return false - const path = url.split('?', 1)[0] + const path = requestPath(url) return path === '/_gateway/metrics' || path.startsWith('/_gateway/audit/') || path.startsWith('/_gateway/async-tasks/') @@ -148,7 +151,7 @@ class ApiClient { // 请求拦截器 - 仅处理认证 this.client.interceptors.request.use( (config) => { - const carriesSessionCredentials = config.url?.includes('/api/') || + const carriesSessionCredentials = requestPath(config.url).startsWith('/api/') || isProtectedOperationalEndpoint(config.url) if (carriesSessionCredentials) { diff --git a/frontend/src/api/endpoints/health-v2.ts b/frontend/src/api/endpoints/health-v2.ts new file mode 100644 index 000000000..27f46bec4 --- /dev/null +++ b/frontend/src/api/endpoints/health-v2.ts @@ -0,0 +1,118 @@ +import client from '../client' + +export type HealthObjectKind = 'api_format' | 'model' | 'provider' +export type PublicHealthObjectKind = Exclude +export type ServiceHealthStatus = 'healthy' | 'degraded' | 'unavailable' | 'unknown' +export type HealthWindow = '1h' | '6h' | '24h' | '72h' + +export interface HealthRatio { + numerator: number + denominator: number + value: number | null +} + +export interface PublicHealthObject { + id: string + kind: HealthObjectKind + name: string + status: ServiceHealthStatus + request_count: number + request_success: HealthRatio + service_availability: HealthRatio + coverage: { + status: 'complete' | 'partial' + sample_status: 'empty' | 'insufficient' | 'sufficient' + classified_count: number + unknown_failure_count: number + excluded_count: number + exclusion_policy: string + } + average_latency_ms: number | null + latency_sample_count: number + last_request_at: string | null + timeline: Array<{ + from: string + to: string + status: ServiceHealthStatus + service_availability: HealthRatio + unknown_failure_count: number + }> +} + +export interface AdminHealthObject extends PublicHealthObject { + source_value: string + attempts: { + succeeded_count: number + failed_count: number + in_progress_count: number + cancelled_count: number + success: HealthRatio + } +} + +export interface HealthMeta { + schema_version: 2 + metric_version: string + scope: { kind: 'published' | 'authenticated' | 'installation'; object_kind: HealthObjectKind } + range: { from: string; to: string; timezone: 'UTC'; time_basis: string } + generated_at: string + data_through: string | null + freshness: 'current' | 'stale' | 'unknown' + policy: { version: string; minimum_samples: number; healthy_threshold: number; degraded_threshold: number } +} + +export interface HealthEnvelope { meta: HealthMeta; data: T } +export interface HealthSummaryV2 { + status: ServiceHealthStatus + object_count: number + healthy_count: number + degraded_count: number + unavailable_count: number + unknown_count: number + requests: PublicHealthObject +} +export interface HealthObjectsPage { items: T[]; total: number; limit: number; offset: number } +export interface HealthQuery { kind: HealthObjectKind; window: HealthWindow; limit?: number; offset?: number } +export type PublicHealthQuery = Omit & { kind: PublicHealthObjectKind } +export interface HealthPublication { + enabled: boolean + objects: Array<{ public_id: string; kind: PublicHealthObjectKind; value: string; display_name: string }> +} + +const adminRoot = '/api/admin/endpoints/health/v2' +const publicRoot = '/api/public/health/v2' +const userRoot = '/api/users/me/health/v2' + +export async function getAdminHealthSummary(query: HealthQuery, signal?: AbortSignal) { + return (await client.get>(`${adminRoot}/summary`, { params: query, signal })).data +} +export async function getPublicHealthSummaryV2(query: PublicHealthQuery, signal?: AbortSignal) { + return (await client.get>(`${publicRoot}/summary`, { params: query, signal })).data +} +export async function getAdminHealthObjects(query: HealthQuery, signal?: AbortSignal) { + return (await client.get>>(`${adminRoot}/objects`, { params: query, signal })).data +} +export async function getPublicHealthObjects(query: PublicHealthQuery, signal?: AbortSignal) { + return (await client.get>>(`${publicRoot}/objects`, { params: query, signal })).data +} +export async function getAdminHealthObject(id: string, query: HealthQuery, signal?: AbortSignal) { + return (await client.get>(`${adminRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data +} +export async function getPublicHealthObject(id: string, query: PublicHealthQuery, signal?: AbortSignal) { + return (await client.get>(`${publicRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data +} +export async function getUserHealthSummary(query: PublicHealthQuery, signal?: AbortSignal) { + return (await client.get>(`${userRoot}/summary`, { params: query, signal })).data +} +export async function getUserHealthObjects(query: PublicHealthQuery, signal?: AbortSignal) { + return (await client.get>>(`${userRoot}/objects`, { params: query, signal })).data +} +export async function getUserHealthObject(id: string, query: PublicHealthQuery, signal?: AbortSignal) { + return (await client.get>(`${userRoot}/objects/${encodeURIComponent(id)}`, { params: query, signal })).data +} +export async function getHealthPublication() { + return (await client.get(`${adminRoot}/publication`)).data +} +export async function saveHealthPublication(publication: HealthPublication) { + return (await client.put(`${adminRoot}/publication`, publication)).data +} diff --git a/frontend/src/api/endpoints/health.ts b/frontend/src/api/endpoints/health.ts index 328dcb664..95ad708f0 100644 --- a/frontend/src/api/endpoints/health.ts +++ b/frontend/src/api/endpoints/health.ts @@ -1,4 +1,5 @@ import client from '../client' +export * from './health-v2' import type { HealthStatus, HealthSummary, diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts new file mode 100644 index 000000000..3b0482237 --- /dev/null +++ b/frontend/src/api/overview.ts @@ -0,0 +1,301 @@ +import apiClient from './client' +import type { GatewayMetricsSummary, AdminMonitoringResilienceStatus } from './monitoring' +import type { ProviderPerformanceResponse } from './admin' + +export interface OverviewRange { + from: string + to: string + timezone: string +} + +export interface OverviewQuery extends OverviewRange { + user_id?: string + api_key_id?: string + request_type?: string + credential_owner_id?: string + attribution_kind?: string + model?: string + provider_id?: string + api_format?: string + endpoint_kind?: string + is_stream?: boolean + has_format_conversion?: boolean + slow_threshold_ms?: number + status?: string + search?: string + account_status?: string + usage_status?: string + sort?: string + order?: 'asc' | 'desc' + group_by?: string + amount_basis?: string + granularity?: 'hour' | 'day' + limit?: number + offset?: number + payment_limit?: number + payment_offset?: number +} + +export interface OverviewAmount { + value: string | null + currency: string + basis: string + status: 'known' | 'known_subtotal' | 'unknown' | 'estimated' | string +} + +export interface OverviewMetrics { + request_count: number + successful_request_count: number + failed_request_count: number + cancelled_request_count: number + in_flight_request_count: number + unclassified_failure_count: number + input_tokens: number | null + output_tokens: number | null + total_tokens: number | null + usage_source?: 'reported' | 'estimated' | 'mixed' | 'unknown' + usage_source_counts?: { reported: number; estimated: number; mixed: number; unknown: number } + requests_per_second?: number + requests_per_minute?: number + tokens_per_minute?: number | null + window_seconds?: number + usage_active_users?: number + enabled_users?: number + success_rate: { value: number | null; numerator: number; denominator: number } + latency_ms: { avg: number | null; p50: number | null; p95: number | null; p99: number | null; sample_count: number } + rated_amount: OverviewAmount + billable_amount: OverviewAmount + quota_covered_amount: OverviewAmount + wallet_consumed_amount: OverviewAmount + wallet_debit_amount: OverviewAmount +} + +export interface OverviewMeta { + schema_version: number + metric_version: string + scope: { kind: string; user_id?: string } + range: OverviewRange & { time_basis: string } + generated_at: string + data_through: string | null + read_revision: string + available_metrics?: string[] + projection?: { + projection_from: string | null + projection_through: string | null + dirty_bucket_count: number + missing_bucket_count: number + read_enabled: boolean + } + coverage: { + status: string + request_count: number + usage_available_count: number + pricing_available_count: number + settled_count: number + attribution_available_count?: number + unrecoverable_bucket_count?: number + } +} + +export interface OverviewResponse { meta: OverviewMeta; data: T } +export type OverviewDashboardTotals = Pick +export interface OverviewDashboard { + today: OverviewResponse + total: OverviewResponse + history_complete: boolean | null +} +export type OverviewDashboardTotal = { status: 'pending' } | { + status: 'ready' + total: OverviewResponse + history_complete: boolean | null + stale: boolean +} +export interface OverviewDashboardSummary { + stats_since: string + generated_at: string + timezone: string + today_from: string + window_seconds: number + today: { + request_count: number + input_tokens: number | null + output_tokens: number | null + total_tokens: number | null + billable_amount: OverviewAmount + active_users: number + cache_read_tokens: number | null + cache_creation_tokens: number | null + cache_input_tokens: number | null + avg_first_byte_ms: number | null + avg_response_ms: number | null + stream_requests: number + standard_requests: number + } + total: { + request_count: number + total_tokens: number | null + billable_amount: OverviewAmount + cache_read_tokens: number | null + cache_input_tokens: number | null + } + users: { total: number; created_today: number; deleted_today: number } + consecutive_active_days: number + active_days: number + activity_days: { date: string; requests: number }[] + concurrency: { + avg: number | null + peak: number | null + observed_from: string | null + observed_through: string | null + scope: 'node' + coverage: 'partial' | 'complete' | 'unavailable' + } +} +export interface OverviewPage { items: T[]; total: number; limit: number; offset: number } +export interface OverviewSeriesPoint extends OverviewMetrics { bucket_start: string } +export interface OverviewBreakdown extends OverviewMetrics { id: string | null; label: string | null } +export interface OverviewDashboardCharts { + summary: OverviewMetrics + series: OverviewSeriesPoint[] + models: (OverviewBreakdown & { bucket_start: string })[] + providers: OverviewBreakdown[] +} +export interface OverviewEmployee extends OverviewMetrics { + user_id: string + username: string + email: string | null + is_active: boolean + last_used_at: string | null + active_days: number + finance?: OverviewUserFinance | null +} +export interface OverviewUserFinance { + wallet_balance: OverviewAmount + recharge_balance: OverviewAmount + gift_balance: OverviewAmount + recharge_amount: OverviewAmount + recharge_count: number + plan_purchase_amount: OverviewAmount + plan_purchase_count: number + gift_credit_amount: OverviewAmount + gift_credit_count: number + balance_time_basis: 'current' + payment_time_basis: 'credited_at' +} +export interface OverviewUserPayment { + id: string + order_no: string + kind: 'wallet_recharge' | 'plan_purchase' | 'gift_credit' + amount: OverviewAmount + payment_method: string + credited_at: string +} +export interface OverviewUsers extends OverviewPage { + summary?: OverviewMetrics & { user_count: number; active_user_count: number } + finance_summary?: OverviewUserFinance | null +} +export interface OverviewEmployeeDetail { + user: { id: string; username: string; email: string | null; is_active: boolean } + summary: OverviewMetrics + finance?: OverviewUserFinance | null + payments?: OverviewPage | null +} +export interface OverviewConsumption { + id: string + request_id: string + started_at: string + user_id: string | null + model: string | null + provider: string | null + status: string + settlement_status: string + attribution_kind: string + rated_amount: OverviewAmount + billable_amount: OverviewAmount + quota_covered_amount: OverviewAmount + wallet_consumed_amount: OverviewAmount + wallet_debit_amount: OverviewAmount +} +export interface OverviewCosts { + summary: OverviewMetrics + timeseries?: OverviewSeriesPoint[] + supplier_estimated_cost: OverviewAmount + supplier_verified_cost: OverviewAmount + cache: { read_tokens: number | null; creation_tokens: number | null; read_cost: OverviewAmount; creation_cost: OverviewAmount; estimated_full_cost: OverviewAmount; estimated_savings: OverviewAmount; pricing_available_count?: number; request_count?: number } + forecast: { amount: OverviewAmount; method: string; status: string; sample_days: number; period_end: string | null } +} +export interface OverviewExecutionActivity { + observed_at: string + observed_from: string + window_seconds: number + observed_window_seconds: number + scope: { kind: 'node' } + coverage: 'complete' | 'partial' + providers: { provider_id: string; provider: string | null; requests_per_minute: number; current_concurrency: number }[] + models: { model: string | null; requests_per_minute: number; current_concurrency: number }[] +} +export interface OverviewLive { + observed_at: string | null + window_seconds: number | null + node_id: string | null + scope: { kind: string; node_ids?: string[] } + metrics?: GatewayMetricsSummary | null + metrics_text?: string | null + resilience: AdminMonitoringResilienceStatus | null + unavailable_sections: string[] + recent_activity?: OverviewResponse + execution_activity?: OverviewExecutionActivity | null +} +export interface OverviewModelPerformance { + model: string | null + request_count: number + success_count: number + error_count: number + success_rate: number | null + avg_first_byte_time_ms: number | null + avg_output_tps: number | null + avg_response_time_ms: number | null +} +export interface OverviewPerformance { + summary: OverviewMetrics + timeseries: OverviewSeriesPoint[] + providers: ProviderPerformanceResponse | null + models?: OverviewModelPerformance[] + errors: { reason: string; count: number }[] +} + +async function get(path: string, params?: OverviewQuery, signal?: AbortSignal): Promise> { + return (await apiClient.get>(`/api/admin/overview/${path}`, { params, signal })).data +} + +export const overviewApi = { + async dashboardSummary(timezone: string, signal?: AbortSignal): Promise { + return (await apiClient.get('/api/admin/overview/dashboard/summary', { params: { timezone }, signal })).data + }, + async dashboard(timezone: string, signal?: AbortSignal): Promise { + return (await apiClient.get('/api/admin/overview/dashboard', { params: { timezone }, signal })).data + }, + async dashboardTotal(timezone: string, signal?: AbortSignal): Promise { + return (await apiClient.get('/api/admin/overview/dashboard/total', { params: { timezone }, signal })).data + }, + async dashboardCharts(range: OverviewRange, signal?: AbortSignal): Promise> { + const { from, to, timezone } = range + return (await apiClient.get>('/api/admin/overview/dashboard/charts', { params: { from, to, timezone, granularity: 'day' }, signal })).data + }, + summary: (query: OverviewQuery, signal?: AbortSignal) => get('summary', query, signal), + timeseries: (query: OverviewQuery, signal?: AbortSignal) => get<{ items: OverviewSeriesPoint[]; granularity: string }>('timeseries', query, signal), + breakdown: (query: OverviewQuery, signal?: AbortSignal) => get>('breakdown', query, signal), + users: (query: OverviewQuery, signal?: AbortSignal) => get('users', query, signal), + user: (id: string, query: OverviewQuery, signal?: AbortSignal) => get(`users/${encodeURIComponent(id)}`, query, signal), + consumption: (query: OverviewQuery, signal?: AbortSignal) => get>('consumption', query, signal), + costs: (query: OverviewQuery, signal?: AbortSignal) => get('costs', query, signal), + performance: (query: OverviewQuery, signal?: AbortSignal) => get('operations/performance', query, signal), + live: (signal?: AbortSignal) => get('operations/live', undefined, signal), + resources: (signal?: AbortSignal) => get('operations/resources', undefined, signal), + async exportCsv(path: 'users' | 'consumption' | 'breakdown', query: OverviewQuery, signal?: AbortSignal): Promise { + const { limit: _limit, offset: _offset, ...filters } = query + return (await apiClient.get(`/api/admin/overview/${path}`, { + params: { ...filters, format: 'csv' }, responseType: 'blob', signal, + })).data + }, +} diff --git a/frontend/src/api/providerFinance.ts b/frontend/src/api/providerFinance.ts new file mode 100644 index 000000000..7693530f1 --- /dev/null +++ b/frontend/src/api/providerFinance.ts @@ -0,0 +1,97 @@ +import client from './client' +import type { OverviewRange } from './overview' + +export type ProviderExpenseKind = 'recharge' | 'subscription' | 'other' +export interface ProviderExpense { + id: string + provider_id: string + provider_name: string + kind: ProviderExpenseKind + amount: string + currency: string + paid_at: string + period_start: string | null + period_end: string | null + note: string | null + external_reference: string | null + status: 'recorded' | 'void' + created_at: string + created_by: string | null + voided_at: string | null +} +export interface ProviderExpenseInput { + client_request_id: string + provider_id: string + kind: ProviderExpenseKind + amount: string + currency: string + paid_at: string + period_start?: string | null + period_end?: string | null + note?: string | null + external_reference?: string | null +} +export interface ProviderExpenseTotals { + currency: string + amount: string + entry_count: number + recharge_amount: string + subscription_amount: string + other_amount: string +} +export interface ProviderExpenses { + items: ProviderExpense[] + total: number + limit: number + offset: number + totals: ProviderExpenseTotals[] + providers: { provider_id: string; provider_name: string; currency: string; amount: string; entry_count: number }[] +} +export interface ProviderAccountSubscription { + group_name: string | null + status: string | null + daily_used_usd: number | null + daily_limit_usd: number | null + weekly_used_usd: number | null + weekly_limit_usd: number | null + monthly_used_usd: number | null + monthly_limit_usd: number | null + expires_at: string | null +} +export interface ProviderAccount { + provider_id: string + provider_name: string + is_active: boolean + billing_type: string | null + quota: { limit: number | string | null; used: number | string | null; remaining: number | string | null; currency: string; period_start: string | null; expires_at: string | null } | null + balance: { + status: string + observed_at: string | null + currency: string | null + available: number | string | null + used: number | string | null + granted: number | string | null + plan_name: string | null + subscriptions: ProviderAccountSubscription[] + } | null +} +export interface ProviderAccounts { observed_at: string; items: ProviderAccount[] } +export type ProviderExpensesQuery = OverviewRange & { limit?: number; offset?: number } +const base = '/api/admin/billing' +export const providerFinanceApi = { + async accounts(signal?: AbortSignal): Promise { + return (await client.get(`${base}/provider-accounts`, { signal })).data + }, + async expenses(params: ProviderExpensesQuery, signal?: AbortSignal): Promise { + return (await client.get(`${base}/provider-expenses`, { params, signal })).data + }, + async record(input: ProviderExpenseInput): Promise<{ item: ProviderExpense }> { + return (await client.post<{ item: ProviderExpense }>(`${base}/provider-expenses`, input)).data + }, + async void(id: string): Promise<{ item: ProviderExpense }> { + return (await client.post<{ item: ProviderExpense }>(`${base}/provider-expenses/${encodeURIComponent(id)}/void`, {})).data + }, + async exportExpenses(range: OverviewRange, signal?: AbortSignal): Promise { + return (await client.get(`${base}/provider-expenses`, { params: { ...range, format: 'csv' }, responseType: 'blob', signal })).data + }, +} diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 12c76cd9f..122781503 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -133,6 +133,8 @@ export interface UsageByApiFormat { } export interface UsageFilters { + from?: string + to?: string user_id?: string // UUID provider_id?: string // UUID model?: string @@ -499,6 +501,17 @@ export const usageApi = { }, async getAllUsageRecords(params?: { + from?: string + to?: string + provider_id?: string + api_key_id?: string + request_id?: string + attribution_kind?: string + endpoint_kind?: string + request_type?: string + is_stream?: boolean + has_format_conversion?: boolean + slow_threshold_ms?: number start_date?: string end_date?: string preset?: string @@ -539,6 +552,17 @@ export const usageApi = { }, async getAllUsageRecordTotal(params?: { + from?: string + to?: string + provider_id?: string + api_key_id?: string + request_id?: string + attribution_kind?: string + endpoint_kind?: string + request_type?: string + is_stream?: boolean + has_format_conversion?: boolean + slow_threshold_ms?: number start_date?: string end_date?: string preset?: string @@ -576,7 +600,7 @@ export const usageApi = { */ async getActiveRequests( ids?: string[], - timeRange?: Pick + timeRange?: Pick ): Promise<{ requests: Array<{ id: string @@ -629,6 +653,10 @@ export const usageApi = { if (ids?.length) { params.ids = ids.join(',') } + if (timeRange?.from && timeRange.to) { + params.from = timeRange.from + params.to = timeRange.to + } if (timeRange?.start_date) { params.start_date = timeRange.start_date } diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 8a9b8d6a7..de41cdb49 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -430,9 +430,14 @@ export const usersApi = { return response.data }, - async listUserPlanEntitlements(userId: string): Promise { + async listUserPlanEntitlements( + userId: string, + options: { include_inactive?: boolean } = {}, + signal?: AbortSignal, + ): Promise { const response = await apiClient.get( - `/api/admin/users/${userId}/billing/entitlements` + `/api/admin/users/${userId}/billing/entitlements`, + { params: options, signal }, ) return response.data }, diff --git a/frontend/src/components/common/AnnouncementBell.vue b/frontend/src/components/common/AnnouncementBell.vue new file mode 100644 index 000000000..667c76ec9 --- /dev/null +++ b/frontend/src/components/common/AnnouncementBell.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/src/components/common/AnnouncementDialog.vue b/frontend/src/components/common/AnnouncementDialog.vue new file mode 100644 index 000000000..82e128342 --- /dev/null +++ b/frontend/src/components/common/AnnouncementDialog.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/frontend/src/components/common/TimeRangePicker.vue b/frontend/src/components/common/TimeRangePicker.vue index 6c4ace0f3..10b86e93e 100644 --- a/frontend/src/components/common/TimeRangePicker.vue +++ b/frontend/src/components/common/TimeRangePicker.vue @@ -6,8 +6,11 @@ - + + {{ presetOnly && selectedPreset === 'custom' ? legacyT('已选时段') : presetLabels[selectedPreset] }} +
{{ legacyT('至') }}
@@ -77,6 +82,7 @@ import { } from '@/components/ui' import type { DateRangeParams } from '@/features/usage/types' import { useI18n } from '@/i18n' +import { browserTimezone, zonedInput, zonedInstant } from '@/features/overview/query' const props = withDefaults(defineProps<{ modelValue: DateRangeParams @@ -84,20 +90,24 @@ const props = withDefaults(defineProps<{ allowHourly?: boolean presetOptions?: SelectablePreset[] presetTriggerClass?: string + presetOnly?: boolean }>(), { presetOptions: () => ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'], presetTriggerClass: undefined, + presetOnly: false, }) const emit = defineEmits<{ 'update:modelValue': [value: DateRangeParams] }>() const { legacyT } = useI18n() -const selectablePresets = ['today', 'yesterday', 'last7days', 'last30days', 'last90days', 'custom'] as const +const selectablePresets = ['last1hour', 'today', 'yesterday', 'last24hours', 'last7days', 'last30days', 'last90days', 'custom'] as const type SelectablePreset = typeof selectablePresets[number] const presetLabels = computed>(() => ({ + last1hour: legacyT('最近 1 小时'), today: legacyT('今天'), yesterday: legacyT('昨天'), + last24hours: legacyT('最近 24 小时'), last7days: legacyT('最近7天'), last30days: legacyT('最近30天'), last90days: legacyT('最近90天'), @@ -106,8 +116,9 @@ const presetLabels = computed>(() => ({ const activePresetOptions = computed(() => { const unique = new Set(props.presetOptions) - const filtered = selectablePresets.filter((preset) => unique.has(preset)) - return filtered.length > 0 ? filtered : [...selectablePresets] + const available = selectablePresets.filter(preset => !props.presetOnly || preset !== 'custom') + const filtered = available.filter((preset) => unique.has(preset)) + return filtered.length > 0 ? filtered : available }) function defaultPreset(): SelectablePreset { @@ -120,15 +131,16 @@ function normalizePreset(value: DateRangeParams): SelectablePreset { if (value.preset && activePresetOptions.value.includes(value.preset as SelectablePreset)) { return value.preset as SelectablePreset } - if (!value.preset && (value.start_date || value.end_date) && activePresetOptions.value.includes('custom')) { + if (!value.preset && (value.from || value.start_date || value.end_date) && (props.presetOnly || activePresetOptions.value.includes('custom'))) { return 'custom' } return defaultPreset() } const selectedPreset = ref(normalizePreset(props.modelValue)) -const startDate = ref(props.modelValue.start_date || '') -const endDate = ref(props.modelValue.end_date || '') +const precise = computed(() => !!props.modelValue.from && !!props.modelValue.to) +const startDate = ref(props.modelValue.from ? zonedInput(props.modelValue.from, props.modelValue.timezone || browserTimezone()) : props.modelValue.start_date || '') +const endDate = ref(props.modelValue.to ? zonedInput(props.modelValue.to, props.modelValue.timezone || browserTimezone()) : props.modelValue.end_date || '') const selectedGranularity = ref(props.modelValue.granularity || 'day') const showGranularity = computed(() => props.showGranularity !== false) @@ -150,6 +162,17 @@ function buildEmitValue(): DateRangeParams { const tz_offset_minutes = -new Date().getTimezoneOffset() if (selectedPreset.value === 'custom') { + if (precise.value) { + const zone = props.modelValue.timezone || timezone + if (startDate.value === zonedInput(props.modelValue.from!, zone) + && endDate.value === zonedInput(props.modelValue.to!, zone)) { + return { ...props.modelValue, granularity: selectedGranularity.value } + } + const from = zonedInstant(startDate.value, zone) + const to = zonedInstant(endDate.value, zone) + if (!from || !to || from >= to) return props.modelValue + return { from, to, timezone: zone, granularity: selectedGranularity.value } + } const start = startDate.value <= endDate.value ? startDate.value : endDate.value const end = endDate.value >= startDate.value ? endDate.value : startDate.value return { @@ -170,6 +193,7 @@ function buildEmitValue(): DateRangeParams { } function getValueKey(value: DateRangeParams): string { + if (value.from && value.to) return `precise:${value.from}:${value.to}:${value.timezone}:${value.granularity}` // 只比较核心字段,忽略 timezone 和 tz_offset_minutes(这些每次都会重新计算) if (value.preset) { return `preset:${value.preset}:${value.granularity}` @@ -179,6 +203,13 @@ function getValueKey(value: DateRangeParams): string { watch(() => props.modelValue, (value) => { selectedPreset.value = normalizePreset(value) + if (value.from && value.to) { + startDate.value = zonedInput(value.from, value.timezone || browserTimezone()) + endDate.value = zonedInput(value.to, value.timezone || browserTimezone()) + } else { + startDate.value = startDate.value.slice(0, 10) + endDate.value = endDate.value.slice(0, 10) + } if (value.start_date !== undefined) startDate.value = value.start_date || '' if (value.end_date !== undefined) endDate.value = value.end_date || '' if (value.granularity) selectedGranularity.value = value.granularity @@ -193,6 +224,8 @@ watch(activePresetOptions, () => { }) watch([selectedPreset, startDate, endDate, selectedGranularity], () => { + // A fixed range can be displayed without exposing a custom date editor. + if (props.presetOnly && selectedPreset.value === 'custom') return if (!allowHourly.value || !canUseHourly.value) { if (selectedGranularity.value === 'hour') { selectedGranularity.value = 'day' @@ -211,5 +244,5 @@ watch([selectedPreset, startDate, endDate, selectedGranularity], () => { lastEmittedValue = newKey emit('update:modelValue', newValue) } -}, { immediate: true }) +}, { immediate: !props.presetOnly }) diff --git a/frontend/src/components/common/__tests__/AnnouncementBell.spec.ts b/frontend/src/components/common/__tests__/AnnouncementBell.spec.ts new file mode 100644 index 000000000..5656b1d08 --- /dev/null +++ b/frontend/src/components/common/__tests__/AnnouncementBell.spec.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, h, nextTick, reactive, type App, type ComputedRef } from 'vue' +import AnnouncementBell from '../AnnouncementBell.vue' +import type { Announcement } from '@/api/announcements' +import { setI18nLocale } from '@/i18n' + +interface PopoverState { open: ComputedRef; toggle: () => void } +vi.mock('@/components/ui/popover', async () => { + const { cloneVNode, computed, defineComponent, h, inject, provide } = await import('vue') + return { + Popover: defineComponent({ + props: { open: Boolean }, emits: ['update:open'], + setup: (props, { slots, emit }) => { + provide('announcement-popover', { open: computed(() => props.open), toggle: () => emit('update:open', !props.open) }) + return () => h('div', slots.default?.()) + }, + }), + PopoverTrigger: defineComponent({ setup: (_, { slots }) => { + const state = inject('announcement-popover')! + return () => cloneVNode(slots.default!()[0]!, { onClick: state.toggle }) + } }), + PopoverContent: defineComponent({ setup: (_, { slots }) => { + const state = inject('announcement-popover')! + return () => state.open.value ? h('section', slots.default?.()) : null + } }), + } +}) +vi.mock('@/components/ui/tooltip', async () => { + const { defineComponent, h } = await import('vue') + const passthrough = defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }) + return { Tooltip: passthrough, TooltipProvider: passthrough, TooltipTrigger: passthrough, TooltipContent: defineComponent({ setup: () => () => null }) } +}) + +const announcement = (is_read = false): Announcement => ({ + id: is_read ? 'read' : 'unread', title: is_read ? 'Earlier notice' : 'Service update', + content: '**Scheduled maintenance** with [details](https://example.com).', type: 'info', + priority: 0, is_pinned: false, is_active: true, requires_ack: false, is_read, + author: { id: 'admin', username: 'Admin' }, created_at: '2026-09-15T02:00:00Z', updated_at: '2026-09-15T02:00:00Z', +}) +type Props = InstanceType['$props'] +const mounted: Array<{ app: App; root: HTMLElement }> = [] +function mount(overrides: Partial = {}) { + const props = reactive({ open: true, items: [] as Announcement[], unreadCount: 0 as number | null, loading: false, error: null as string | null, hasMore: false, markingAll: false, canManage: false, ...overrides }) + const events = { select: vi.fn(), refresh: vi.fn(), loadMore: vi.fn(), readAll: vi.fn(), create: vi.fn(), open: vi.fn() } + const root = document.createElement('div') + document.body.append(root) + const app = createApp(() => h(AnnouncementBell, { + ...props, + 'onUpdate:open': value => { events.open(value); props.open = value }, + onSelect: events.select, onRefresh: events.refresh, onLoadMore: events.loadMore, + onReadAll: events.readAll, onCreate: events.create, + })) + app.mount(root) + mounted.push({ app, root }) + return { root, props, events } +} +function button(root: HTMLElement, label: string) { + const result = Array.from(root.querySelectorAll('button')).find(item => item.getAttribute('aria-label') === label || item.textContent?.trim() === label) + if (!result) throw new Error(`Button not found: ${label}`) + return result +} +beforeEach(() => setI18nLocale('zh-CN')) +afterEach(() => { for (const { app, root } of mounted.splice(0)) { app.unmount(); root.remove() } }) + +describe('AnnouncementBell presentation', () => { + it('keeps unknown and zero counts hidden, caps the visible badge, and emits controlled open changes', async () => { + const { root, props, events } = mount({ unreadCount: null, open: false }) + expect(root.querySelector('[data-unread-badge]')).toBeNull() + expect(button(root, '公告').className).toContain('h-9 w-9') + button(root, '公告').click() + await nextTick() + expect(events.open).toHaveBeenCalledWith(true) + expect(button(root, '全部标为已读').disabled).toBe(true) + props.unreadCount = 0 + await nextTick() + expect(root.querySelector('[data-unread-badge]')).toBeNull() + props.unreadCount = 137 + await nextTick() + expect(root.querySelector('[data-unread-badge]')?.textContent).toBe('99+') + expect(button(root, '公告,137 条未读').getAttribute('title')).toBe('公告,137 条未读') + expect(button(root, '全部标为已读').disabled).toBe(false) + }) + + it('emits selection without changing reading state and renders plain Markdown summaries', () => { + const unread = announcement() + const { root, events } = mount({ items: [unread, announcement(true)], unreadCount: 1 }) + expect(root.textContent).toContain('Scheduled maintenance with details.') + expect(root.querySelector('a')).toBeNull() + expect(button(root, 'Service update, 未读')).toBeTruthy() + expect(button(root, 'Earlier notice, 已读')).toBeTruthy() + button(root, 'Service update, 未读').click() + expect(events.select).toHaveBeenCalledWith(unread) + expect(unread.is_read).toBe(false) + expect(events.readAll).not.toHaveBeenCalled() + }) + + it('retains rows during refresh failures and provides retry, loading, and pagination states', async () => { + const { root, props, events } = mount({ items: [announcement()], error: '公告加载失败', hasMore: true }) + expect(root.querySelector('[role="alert"]')?.textContent).toContain('公告加载失败') + button(root, '重试').click() + button(root, '加载更多').click() + expect(events.refresh).toHaveBeenCalledTimes(1) + expect(events.loadMore).toHaveBeenCalledTimes(1) + props.loading = true + await nextTick() + expect(root.querySelector('[role="status"]')?.textContent).toContain('加载中') + expect(button(root, 'Service update, 未读')).toBeTruthy() + expect(button(root, '重试').disabled).toBe(true) + expect(button(root, '加载更多').disabled).toBe(true) + }) + + it('keeps publishing, refresh, and mark-all commands without a management navigation button', async () => { + const { root, props, events } = mount({ unreadCount: 2 }) + expect(root.querySelector('[aria-label="发布公告"]')).toBeNull() + expect(root.querySelector('[aria-label="管理公告"]')).toBeNull() + props.canManage = true + await nextTick() + expect(root.querySelector('[aria-label="管理公告"]')).toBeNull() + button(root, '发布公告').click() + button(root, '刷新公告').click() + button(root, '全部标为已读').click() + expect(events.create).toHaveBeenCalledTimes(1) + expect(events.refresh).toHaveBeenCalledTimes(1) + expect(events.readAll).toHaveBeenCalledTimes(1) + props.markingAll = true + await nextTick() + expect(button(root, '全部标为已读').disabled).toBe(true) + button(root, '全部标为已读').click() + expect(events.readAll).toHaveBeenCalledTimes(1) + }) + + it('shows an empty state without inventing an unread count and follows the active locale', async () => { + const { root } = mount({ unreadCount: null }) + expect(root.textContent).toContain('暂无公告') + setI18nLocale('en-US') + await nextTick() + expect(button(root, 'Announcements')).toBeTruthy() + expect(root.textContent).toContain('No announcements') + expect(root.querySelector('[data-unread-badge]')).toBeNull() + }) +}) diff --git a/frontend/src/components/common/__tests__/TimeRangePicker.spec.ts b/frontend/src/components/common/__tests__/TimeRangePicker.spec.ts new file mode 100644 index 000000000..c22e8f994 --- /dev/null +++ b/frontend/src/components/common/__tests__/TimeRangePicker.spec.ts @@ -0,0 +1,100 @@ +import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import TimeRangePicker from '../TimeRangePicker.vue' +import type { DateRangeParams } from '@/features/usage/types' + +vi.mock('@/components/ui', async () => { + const { defineComponent, h } = await import('vue') + const passthrough = defineComponent({ setup: (_, { slots }) => () => slots.default?.() }) + return { + Select: defineComponent({ + props: { modelValue: String }, emits: ['update:modelValue'], + setup: (props, { emit, slots }) => () => h('select', { + value: props.modelValue, + onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value), + }, slots.default?.()), + }), + SelectItem: defineComponent({ props: { value: String }, setup: (props, { slots }) => () => h('option', { value: props.value }, slots.default?.()) }), + SelectContent: passthrough, SelectTrigger: defineComponent({ render: () => null }), SelectValue: passthrough, + Input: defineComponent({ + props: { modelValue: String, type: String }, emits: ['update:modelValue'], + setup: (props, { emit }) => () => h('input', { + type: props.type, value: props.modelValue, + onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value), + }), + }), + } +}) + +let app: App | undefined +afterEach(() => { app?.unmount(); app = undefined }) + +describe('time range mode changes', () => { + it('keeps a fixed range read-only in preset-only mode without emitting a replacement on mount', async () => { + const range = ref({ from: '2026-09-18T00:00:00Z', to: '2026-09-18T01:00:00Z', timezone: 'UTC' }) + const update = vi.fn((value: DateRangeParams) => { range.value = value }) + const root = document.createElement('div') + app = createApp(defineComponent({ setup: () => () => h(TimeRangePicker, { + modelValue: range.value, showGranularity: false, presetOnly: true, + presetOptions: ['last1hour', 'today', 'last24hours', 'last7days'], + 'onUpdate:modelValue': update, + }) })) + app.mount(root) + await nextTick() + expect(update).not.toHaveBeenCalled() + expect(root.querySelector('input')).toBeNull() + expect(Array.from(root.querySelectorAll('option'), option => option.value)).toEqual(['last1hour', 'today', 'last24hours', 'last7days']) + expect(range.value).toMatchObject({ from: '2026-09-18T00:00:00Z', to: '2026-09-18T01:00:00Z', timezone: 'UTC' }) + const select = root.querySelector('select')! + select.value = 'last1hour' + select.dispatchEvent(new Event('change')) + await nextTick() + expect(update).toHaveBeenCalledTimes(1) + expect(range.value.preset).toBe('last1hour') + expect(root.querySelector('input')).toBeNull() + }) + + it('does not repeat a preset selection when the parent synchronizes its range', async () => { + const range = ref({ preset: 'today', timezone: 'UTC', granularity: 'day' }) + const update = vi.fn() + const root = document.createElement('div') + app = createApp(defineComponent({ setup: () => () => h(TimeRangePicker, { + modelValue: range.value, showGranularity: false, presetOnly: true, + presetOptions: ['last1hour', 'today', 'last24hours'], + 'onUpdate:modelValue': update, + }) })) + app.mount(root) + await nextTick() + range.value = { ...range.value } + await nextTick() + expect(update).not.toHaveBeenCalled() + range.value = { ...range.value, preset: 'last24hours' } + await nextTick() + expect(root.querySelector('select')?.value).toBe('last24hours') + expect(update).not.toHaveBeenCalled() + }) + + it('keeps valid calendar dates after precise range → preset → custom', async () => { + const range = ref({ from: '2026-09-18T00:00:00Z', to: '2026-09-18T01:00:00Z', timezone: 'UTC' }) + const root = document.createElement('div') + app = createApp(defineComponent({ setup: () => () => h(TimeRangePicker, { + modelValue: range.value, showGranularity: false, + 'onUpdate:modelValue': value => { range.value = value }, + }) })) + app.mount(root) + await nextTick() + expect(root.querySelector('input')?.type).toBe('datetime-local') + const select = root.querySelector('select')! + select.value = 'today' + select.dispatchEvent(new Event('change')) + await nextTick() + select.value = 'custom' + select.dispatchEvent(new Event('change')) + await nextTick() + expect(range.value).toMatchObject({ start_date: '2026-09-18', end_date: '2026-09-18' }) + expect(range.value.from).toBeUndefined() + expect(Array.from(root.querySelectorAll('input')).map(input => [input.type, input.value])).toEqual([ + ['date', '2026-09-18'], ['date', '2026-09-18'], + ]) + }) +}) diff --git a/frontend/src/components/common/announcementContext.ts b/frontend/src/components/common/announcementContext.ts new file mode 100644 index 000000000..872a8db96 --- /dev/null +++ b/frontend/src/components/common/announcementContext.ts @@ -0,0 +1,4 @@ +import type { InjectionKey } from 'vue' +import type { Announcement } from '@/api/announcements' + +export const openAnnouncementKey: InjectionKey<(announcement: Announcement) => void> = Symbol('open-announcement') diff --git a/frontend/src/components/stats/ActivityHeatmap.vue b/frontend/src/components/stats/ActivityHeatmap.vue index 74e179ee5..a0d274baa 100644 --- a/frontend/src/components/stats/ActivityHeatmap.vue +++ b/frontend/src/components/stats/ActivityHeatmap.vue @@ -12,9 +12,15 @@ {{ formatDay(tooltip.day.date) }}

- {{ t('heatmap.requests', { count: tooltip.day.requests }) }} · {{ formatTokens(tooltip.day.total_tokens) }} + {{ t('heatmap.requests', { count: tooltip.day.requests }) }} +

-

+

{{ t('heatmap.cost', { value: formatCurrency(tooltip.day.total_cost) }) }}

@@ -52,14 +58,20 @@
-