mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
fix(gateway): restore account usage and request observability
Restore provider-native quota and reasoning observations, complete request lifecycle accounting, and align account and usage surfaces with the restored semantics. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -7,7 +7,7 @@ use futures::Stream;
|
||||
|
||||
use gateway_core::routing::ProviderKind;
|
||||
|
||||
use super::{PageSize, Revision};
|
||||
use super::{PageSize, Revision, observability::TimeRange};
|
||||
|
||||
/// Provider 账号当前可用性。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -151,6 +151,24 @@ pub struct AccountUsage {
|
||||
pub models: Vec<AccountModelUsage>,
|
||||
}
|
||||
|
||||
/// 某个账号在调用方指定时间窗口内的本地用量查询。
|
||||
///
|
||||
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsageWindowQuery {
|
||||
pub account_id: String,
|
||||
pub key: String,
|
||||
pub range: TimeRange,
|
||||
}
|
||||
|
||||
/// 一个账号时间窗口用量查询的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsageWindowResult {
|
||||
pub account_id: String,
|
||||
pub key: String,
|
||||
pub usage: AccountUsage,
|
||||
}
|
||||
|
||||
/// 账号列表页所需的完整存储事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPage {
|
||||
|
||||
@@ -327,6 +327,21 @@ pub struct ProviderBillingInput {
|
||||
pub total: CurrencyCost,
|
||||
}
|
||||
|
||||
/// 已完整交付且由 Provider 计算费用的持久请求事实。
|
||||
///
|
||||
/// 控制面仅保留通用事实,具体 Provider 负责校验已持久化总额并恢复标准费用。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageCalculatedBillingFact {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub provider_kind: String,
|
||||
pub upstream_model_id: String,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub total: CurrencyCost,
|
||||
}
|
||||
|
||||
/// Provider 已确认的逐项费用与单价。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalculatedBillingBreakdown {
|
||||
@@ -549,6 +564,8 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
|
||||
pub provider_metadata_json: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub upstream_send_state: String,
|
||||
pub downstream_committed_at: Option<DateTime<Utc>>,
|
||||
|
||||
@@ -10,8 +10,8 @@ use chrono::{DateTime, Utc};
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccounts,
|
||||
SetAccountEnabled,
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
@@ -20,8 +20,8 @@ use crate::model::{
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail, UsageFilter,
|
||||
UsageOverview, UsagePage, UsageQuery,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
@@ -90,6 +90,11 @@ pub trait AccountStore: Send + Sync {
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<Vec<AccountUsage>>;
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
@@ -249,6 +254,13 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
/// 返回可由 Provider 重新校验的已计算费用事实,用于恢复标准费用趋势。
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>>;
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
|
||||
async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult<UsageDetail>;
|
||||
|
||||
@@ -19,12 +19,13 @@ use crate::{
|
||||
AdminError, MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
|
||||
AccountRecord, AccountStatus,
|
||||
AccountRecord, AccountStatus, AccountUsageWindowQuery,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
PrepareCredentialRefresh, ProviderModels, ProviderQuotaRequest,
|
||||
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
@@ -132,6 +133,45 @@ impl DefaultAccountsService {
|
||||
Ok((account, provider))
|
||||
}
|
||||
|
||||
async fn attach_quota_local_usage(
|
||||
&self,
|
||||
accounts: &[AccountRecord],
|
||||
quotas: &mut [ProviderQuota],
|
||||
) -> Result<(), AdminError> {
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.zip(quotas.iter())
|
||||
.flat_map(|(account, quota)| {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.filter_map(|window| quota_usage_window(&account.id, window))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if windows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let usage_by_window = self
|
||||
.accounts
|
||||
.load_account_usage_by_windows(&windows)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "quota window usage"))?
|
||||
.into_iter()
|
||||
.map(|result| ((result.account_id, result.key), result.usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (account, quota) in accounts.iter().zip(quotas) {
|
||||
for window in &mut quota.windows {
|
||||
if window.local_usage.is_none() {
|
||||
let key = (account.id.clone(), window.key.clone());
|
||||
if let Some(usage) = usage_by_window.get(&key) {
|
||||
window.local_usage = Some(usage.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_directory_item(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -165,7 +205,7 @@ impl DefaultAccountsService {
|
||||
},
|
||||
)?;
|
||||
let rolling_usage = rolling_usage.into_iter().next();
|
||||
let quota = provider
|
||||
let mut quota = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: refresh_quota,
|
||||
@@ -173,6 +213,11 @@ impl DefaultAccountsService {
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
self.attach_quota_local_usage(
|
||||
std::slice::from_ref(&account),
|
||||
std::slice::from_mut(&mut quota),
|
||||
)
|
||||
.await?;
|
||||
Ok(AccountDirectoryItem {
|
||||
status: account_status(&account, now),
|
||||
usage: usage.into_iter().next(),
|
||||
@@ -214,7 +259,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let quotas = futures::future::join_all(page.items.iter().map(|account| async {
|
||||
let mut quotas = futures::future::join_all(page.items.iter().map(|account| async {
|
||||
let account_id = ProviderAccountId::new(account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
|
||||
let provider = self
|
||||
@@ -233,6 +278,8 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
self.attach_quota_local_usage(&page.items, &mut quotas)
|
||||
.await?;
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -451,3 +498,22 @@ fn retained_usage_range(now: chrono::DateTime<Utc>, retention_days: u32) -> Time
|
||||
end: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_usage_window(
|
||||
account_id: &str,
|
||||
window: &ProviderQuotaWindow,
|
||||
) -> Option<AccountUsageWindowQuery> {
|
||||
if window.local_usage.is_some() {
|
||||
return None;
|
||||
}
|
||||
let reset_at = window.reset_at?;
|
||||
let seconds = i64::try_from(window.window_seconds?).ok()?;
|
||||
let start = reset_at.checked_sub_signed(Duration::seconds(seconds))?;
|
||||
let range = TimeRange::new(start, reset_at).ok()?;
|
||||
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。
|
||||
Some(AccountUsageWindowQuery {
|
||||
account_id: account_id.to_owned(),
|
||||
key: window.key.clone(),
|
||||
range,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 观测查询、趋势、费用覆盖与健康阈值规则。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, btree_map::Entry},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
@@ -14,15 +17,16 @@ use crate::{
|
||||
DashboardPeriodMetrics, DashboardResult, DecimalAmount, DiagnosticDimension,
|
||||
DiagnosticsItem, DiagnosticsResult, HealthStatus, HealthTimeline, HealthTimelinePoint,
|
||||
OpsErrorPage, OpsErrorQuery, ProviderBillingInput, RequestMetricPoint, RequestMetrics,
|
||||
TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling, UsageDetail,
|
||||
UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
|
||||
UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance,
|
||||
UsageInsightsPerformancePoint, UsageOverview, UsagePage, UsageQuery, UsageSummary,
|
||||
TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::{ObservabilityStore, SettingsStore},
|
||||
},
|
||||
};
|
||||
@@ -215,12 +219,14 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let (overview, trend) = futures::try_join!(
|
||||
let (overview, trend, billing_facts) = futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
self.store.usage_calculated_billing_facts(range, filter),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))?;
|
||||
build_usage_insights(overview, trend)
|
||||
let standard_costs = recover_standard_costs(&self.providers, billing_facts)?;
|
||||
build_usage_insights(overview, trend, standard_costs)
|
||||
}
|
||||
|
||||
async fn diagnostics(
|
||||
@@ -268,6 +274,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
fn build_usage_insights(
|
||||
overview: UsageOverview,
|
||||
trend: Vec<RequestMetricPoint>,
|
||||
standard_costs: UsageStandardCosts,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let granularity = trend.first().map_or(
|
||||
crate::model::observability::Granularity::FifteenMinutes,
|
||||
@@ -325,12 +332,16 @@ fn build_usage_insights(
|
||||
.collect(),
|
||||
};
|
||||
let estimated_cost = usd_cost(&overview.attempts.costs);
|
||||
let UsageStandardCosts {
|
||||
total: standard_cost,
|
||||
by_bucket: standard_costs_by_bucket,
|
||||
} = standard_costs;
|
||||
let cost = UsageInsightsCost {
|
||||
cost_per_request: estimated_cost
|
||||
.as_ref()
|
||||
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
|
||||
estimated_cost,
|
||||
standard_cost: None,
|
||||
standard_cost,
|
||||
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
|
||||
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
|
||||
cache_hit_request_rate: ratio(
|
||||
@@ -350,7 +361,7 @@ fn build_usage_insights(
|
||||
cached_tokens: point.metrics.cached_tokens,
|
||||
total_tokens: point.metrics.total_tokens,
|
||||
estimated_cost: usd_cost(&point.costs),
|
||||
standard_cost: None,
|
||||
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
|
||||
cached_token_rate: rate_or_zero(
|
||||
point.metrics.cached_tokens,
|
||||
point.metrics.input_tokens,
|
||||
@@ -374,6 +385,76 @@ fn build_usage_insights(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageStandardCosts {
|
||||
total: Option<DecimalAmount>,
|
||||
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
|
||||
}
|
||||
|
||||
impl UsageStandardCosts {
|
||||
fn add(
|
||||
&mut self,
|
||||
bucket_start: DateTime<Utc>,
|
||||
amount: DecimalAmount,
|
||||
) -> Result<(), AdminError> {
|
||||
self.total = Some(match self.total.take() {
|
||||
Some(current) => current
|
||||
.checked_add(&amount)
|
||||
.ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?,
|
||||
None => amount.clone(),
|
||||
});
|
||||
match self.by_bucket.entry(bucket_start) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let sum = entry.get().checked_add(&amount).ok_or_else(|| {
|
||||
AdminError::internal("Usage standard cost exceeds decimal range")
|
||||
})?;
|
||||
entry.insert(sum);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(amount);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_standard_costs(
|
||||
providers: &ProviderAdminRegistry,
|
||||
facts: Vec<UsageCalculatedBillingFact>,
|
||||
) -> Result<UsageStandardCosts, AdminError> {
|
||||
let mut standard_costs = UsageStandardCosts::default();
|
||||
for fact in facts {
|
||||
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: fact.upstream_model_id,
|
||||
input_tokens: fact.input_tokens,
|
||||
output_tokens: fact.output_tokens,
|
||||
cached_tokens: fact.cached_tokens,
|
||||
cache_write_tokens: fact.cache_write_tokens,
|
||||
total: fact.total,
|
||||
};
|
||||
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
|
||||
Ok(breakdown) => breakdown,
|
||||
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
|
||||
Err(error) => return Err(map_provider_error(error, "usage billing")),
|
||||
};
|
||||
let Some(breakdown) = breakdown else {
|
||||
continue;
|
||||
};
|
||||
if !breakdown
|
||||
.standard_amount
|
||||
.currency
|
||||
.eq_ignore_ascii_case("USD")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
|
||||
}
|
||||
Ok(standard_costs)
|
||||
}
|
||||
|
||||
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
costs
|
||||
.iter()
|
||||
|
||||
@@ -18,7 +18,8 @@ use gateway_admin::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountAvailability, AccountListQuery, AccountPage, AccountRecord, AccountSummary,
|
||||
AccountUsage, DeleteAccounts, SetAccountEnabled,
|
||||
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, DeleteAccounts,
|
||||
SetAccountEnabled,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
@@ -31,7 +32,7 @@ use gateway_admin::{
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -59,6 +60,7 @@ pub(super) struct FakeProviderAdmin {
|
||||
export_inputs: Mutex<Vec<ProviderExportCredentialInput>>,
|
||||
import_account_ids: Mutex<Vec<String>>,
|
||||
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
|
||||
quota: Mutex<ProviderQuota>,
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
@@ -72,6 +74,7 @@ impl FakeProviderAdmin {
|
||||
export_inputs: Mutex::new(Vec::new()),
|
||||
import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]),
|
||||
quota_requests: Mutex::new(Vec::new()),
|
||||
quota: Mutex::new(empty_quota()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,6 +103,10 @@ impl FakeProviderAdmin {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn set_quota(&self, quota: ProviderQuota) {
|
||||
*self.quota.lock().expect("provider quota") = quota;
|
||||
}
|
||||
|
||||
pub(super) fn pending(&self) -> Option<PendingAuthorizationMutation> {
|
||||
self.pending.lock().expect("pending authorization").clone()
|
||||
}
|
||||
@@ -282,12 +289,7 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
{
|
||||
return Err(ProviderAdminError::new(kind));
|
||||
}
|
||||
Ok(ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
provider_data: None,
|
||||
})
|
||||
Ok(self.quota.lock().expect("provider quota").clone())
|
||||
}
|
||||
|
||||
async fn models(
|
||||
@@ -327,6 +329,7 @@ pub(super) struct FakeAccountStore {
|
||||
fail_commit: Mutex<bool>,
|
||||
audit_requests: Mutex<Vec<String>>,
|
||||
authorization_revision: Mutex<Option<Revision>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
}
|
||||
|
||||
impl FakeAccountStore {
|
||||
@@ -337,6 +340,7 @@ impl FakeAccountStore {
|
||||
fail_commit: Mutex::new(false),
|
||||
audit_requests: Mutex::new(Vec::new()),
|
||||
authorization_revision: Mutex::new(None),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,6 +359,10 @@ impl FakeAccountStore {
|
||||
.expect("authorization revision")
|
||||
}
|
||||
|
||||
pub(super) fn set_quota_window_usage(&self, usage: Vec<AccountUsageWindowResult>) {
|
||||
*self.quota_window_usage.lock().expect("quota window usage") = usage;
|
||||
}
|
||||
|
||||
fn record(&self, event: &'static str) {
|
||||
self.events.lock().expect("store events").push(event);
|
||||
}
|
||||
@@ -405,6 +413,17 @@ impl AccountStore for FakeAccountStore {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
_: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
Ok(self
|
||||
.quota_window_usage
|
||||
.lock()
|
||||
.expect("quota window usage")
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -758,6 +777,54 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::hours(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "primary".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
source: None,
|
||||
window_seconds: Some(5 * 60 * 60),
|
||||
used_percent: Some(97.0),
|
||||
reset_at: Some(reset_at),
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
provider_data: None,
|
||||
});
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
store.set_quota_window_usage(vec![AccountUsageWindowResult {
|
||||
account_id: "acct_test".to_owned(),
|
||||
key: "primary".to_owned(),
|
||||
usage: quota_local_usage("acct_test", 4_330_000),
|
||||
}]);
|
||||
|
||||
let page = accounts_service(provider, store)
|
||||
.await
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
|
||||
provider_kind: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.expect("quota window usage");
|
||||
|
||||
let usage = page.items[0].quota.windows[0]
|
||||
.local_usage
|
||||
.as_ref()
|
||||
.expect("quota window local usage");
|
||||
assert_eq!(usage.total_tokens, Some(4_330_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
let events = events();
|
||||
@@ -832,6 +899,38 @@ pub(super) fn document() -> ProviderDocument {
|
||||
ProviderDocument::new(OpaqueProviderData::new(Default::default()))
|
||||
}
|
||||
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
AccountUsage {
|
||||
account_id: account_id.to_owned(),
|
||||
request_count: 1,
|
||||
success_count: 1,
|
||||
input_tokens: Some(total_tokens),
|
||||
output_tokens: Some(0),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(0),
|
||||
image_output_tokens: Some(0),
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: Some(total_tokens),
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: Some(Utc::now()),
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
|
||||
@@ -7,7 +7,10 @@ mod settings;
|
||||
mod system;
|
||||
mod xai;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::{
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -16,8 +19,8 @@ use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccounts,
|
||||
SetAccountEnabled,
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
@@ -244,6 +247,13 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("account usage"))
|
||||
}
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
_: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
Err(unavailable("account quota window usage"))
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
@@ -389,6 +399,15 @@ impl ObservabilityStore for UnavailableStore {
|
||||
Err(unavailable("usage trend"))
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
|
||||
{
|
||||
Err(unavailable("usage billing facts"))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
Err(unavailable("usage records"))
|
||||
}
|
||||
@@ -454,6 +473,7 @@ impl SettingsStore for UnavailableStore {
|
||||
struct UnavailableProvider {
|
||||
kind: ProviderKind,
|
||||
dashboard_profile: Option<DashboardWireProfile>,
|
||||
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
|
||||
}
|
||||
|
||||
impl UnavailableProvider {
|
||||
@@ -461,6 +481,7 @@ impl UnavailableProvider {
|
||||
Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
dashboard_profile: None,
|
||||
calculated_billing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,7 +513,7 @@ impl ProviderAdmin for UnavailableProvider {
|
||||
Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
|
||||
ProviderAdminError,
|
||||
> {
|
||||
Ok(None)
|
||||
Ok(self.calculated_billing.clone())
|
||||
}
|
||||
|
||||
async fn prepare_import(
|
||||
@@ -587,6 +608,35 @@ pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
|
||||
error: None,
|
||||
}),
|
||||
}),
|
||||
calculated_billing: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
|
||||
let amount = |value| gateway_admin::model::observability::CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str(value)
|
||||
.expect("test billing amount"),
|
||||
};
|
||||
Arc::new(UnavailableProvider {
|
||||
kind: ProviderKind::new("openai").expect("provider kind"),
|
||||
dashboard_profile: None,
|
||||
calculated_billing: Some(
|
||||
gateway_admin::model::observability::CalculatedBillingBreakdown {
|
||||
input_amount: amount("0.8"),
|
||||
output_amount: amount("0.2"),
|
||||
cache_read_amount: amount("0"),
|
||||
cache_write_amount: amount("0"),
|
||||
standard_amount: amount("1"),
|
||||
total_amount: amount("1.25"),
|
||||
input_price_per_million: amount("0"),
|
||||
output_price_per_million: amount("0"),
|
||||
cache_read_price_per_million: amount("0"),
|
||||
cache_write_price_per_million: amount("0"),
|
||||
service_tier: None,
|
||||
multiplier_percent: 125,
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ use gateway_admin::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, RequestMetrics,
|
||||
TimeRange, TrendKind, UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
TimeRange, TrendKind, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -242,8 +243,22 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
.expect("bucket USD cost"),
|
||||
}],
|
||||
}]);
|
||||
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
|
||||
bucket_start: quarter_hour_start(now),
|
||||
provider_kind: "openai".to_owned(),
|
||||
upstream_model_id: "gpt-5.5".to_owned(),
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
|
||||
.expect("calculated total"),
|
||||
},
|
||||
}]);
|
||||
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
|
||||
let services = observability_services(store).await;
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let insights = services
|
||||
.observability()
|
||||
@@ -284,6 +299,21 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("0.25")
|
||||
);
|
||||
assert_eq!(
|
||||
insights
|
||||
.cost
|
||||
.standard_cost
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
insights.cost.points[0]
|
||||
.standard_cost
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(diagnostics.items[0].request_share, 0.75);
|
||||
assert_eq!(diagnostics.items[1].request_share, 0.25);
|
||||
}
|
||||
@@ -291,6 +321,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
struct FixtureObservabilityStore {
|
||||
trend: Mutex<Vec<RequestMetricPoint>>,
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
}
|
||||
@@ -305,6 +336,7 @@ impl FixtureObservabilityStore {
|
||||
attempts: AttemptMetrics::default(),
|
||||
providers: Vec::new(),
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
}
|
||||
@@ -318,6 +350,13 @@ impl FixtureObservabilityStore {
|
||||
*self.overview.lock().expect("overview") = overview;
|
||||
}
|
||||
|
||||
fn replace_calculated_billing_facts(&self, facts: Vec<UsageCalculatedBillingFact>) {
|
||||
*self
|
||||
.calculated_billing_facts
|
||||
.lock()
|
||||
.expect("calculated billing facts") = facts;
|
||||
}
|
||||
|
||||
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
|
||||
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
|
||||
}
|
||||
@@ -360,6 +399,18 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
Ok(self.trend.lock().expect("trend").clone())
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>> {
|
||||
Ok(self
|
||||
.calculated_billing_facts
|
||||
.lock()
|
||||
.expect("calculated billing facts")
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
Err(super::unavailable("usage records"))
|
||||
}
|
||||
@@ -445,6 +496,17 @@ async fn observability_services(store: Arc<FixtureObservabilityStore>) -> AdminS
|
||||
.await
|
||||
}
|
||||
|
||||
async fn observability_services_with_calculated_billing(
|
||||
store: Arc<FixtureObservabilityStore>,
|
||||
) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore))
|
||||
.provider(super::calculated_billing_provider())
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn observation_range(end: DateTime<Utc>) -> TimeRange {
|
||||
TimeRange::new(end - Duration::hours(24), end).expect("observation range")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Dashboard、用量与诊断查询的 wire 映射和固定路由。
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
use std::{collections::BTreeMap, num::NonZeroU32};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -13,6 +13,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
|
||||
@@ -658,10 +659,20 @@ pub struct UsageRecordMetadataView {
|
||||
pub response_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub websocket_pool: Option<WebSocketPoolMetadataView>,
|
||||
pub image_generation_requested: bool,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
pub latency_details: UsageLatencyDetailsView,
|
||||
/// Provider 安全观测直接展开,避免 API 层理解任何 Provider 专属字段。
|
||||
#[serde(flatten)]
|
||||
pub provider_metadata: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
/// WebSocket 池决策的稳定形状;与 Provider 选择逻辑无关。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebSocketPoolMetadataView {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// 逻辑请求在上游和输出阶段测得的时延事实。
|
||||
@@ -1759,7 +1770,10 @@ fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
|
||||
upstream_status_code: record.upstream_status_code.map(i64::from),
|
||||
response_id: record.client_response_id.clone(),
|
||||
upstream_request_id: record.upstream_request_id.clone(),
|
||||
websocket_pool: record.websocket_pool.clone(),
|
||||
websocket_pool: record
|
||||
.websocket_pool
|
||||
.clone()
|
||||
.map(|kind| WebSocketPoolMetadataView { kind }),
|
||||
image_generation_requested: record.image_generation_requested,
|
||||
image_generation_succeeded: record.image_generation_succeeded,
|
||||
latency_details: UsageLatencyDetailsView {
|
||||
@@ -1772,6 +1786,7 @@ fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
|
||||
first_token_ms: record.first_token_ms,
|
||||
openai_processing_ms: record.provider_processing_ms,
|
||||
},
|
||||
provider_metadata: provider_metadata_fields(record.provider_metadata_json.as_deref()),
|
||||
};
|
||||
UsageRecordView {
|
||||
id: record.id.clone(),
|
||||
@@ -1824,6 +1839,40 @@ fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_metadata_fields(value: Option<&str>) -> BTreeMap<String, Value> {
|
||||
const CORE_METADATA_FIELDS: &[&str] = &[
|
||||
"protocol",
|
||||
"logicalOutcome",
|
||||
"attemptCount",
|
||||
"requestedModel",
|
||||
"upstreamModel",
|
||||
"clientIp",
|
||||
"userAgent",
|
||||
"reasoningEffort",
|
||||
"reasoningPreset",
|
||||
"compact",
|
||||
"requestKind",
|
||||
"subagentKind",
|
||||
"transport",
|
||||
"httpVersion",
|
||||
"clientStatusCode",
|
||||
"upstreamStatusCode",
|
||||
"responseId",
|
||||
"upstreamRequestId",
|
||||
"websocketPool",
|
||||
"imageGenerationRequested",
|
||||
"imageGenerationSucceeded",
|
||||
"latencyDetails",
|
||||
];
|
||||
let Some(Ok(Value::Object(fields))) = value.map(serde_json::from_str::<Value>) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
fields
|
||||
.into_iter()
|
||||
.filter(|(field, _)| !CORE_METADATA_FIELDS.contains(&field.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptView {
|
||||
let occurred_at = attempt.occurred_at;
|
||||
UsageAttemptView {
|
||||
|
||||
@@ -14,8 +14,8 @@ use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, DeleteAccounts,
|
||||
SetAccountEnabled,
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
@@ -441,6 +441,13 @@ impl AccountStore for UnusedStore {
|
||||
Err(unavailable("account usage"))
|
||||
}
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
_: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
Err(unavailable("account quota window usage"))
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
@@ -540,6 +547,15 @@ impl ObservabilityStore for UnusedStore {
|
||||
Err(unavailable("usage trend"))
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
|
||||
{
|
||||
Err(unavailable("usage billing facts"))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
Ok(UsagePage {
|
||||
|
||||
@@ -326,6 +326,14 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
http_version: Some("h2".to_owned()),
|
||||
websocket_pool: Some("reuse".to_owned()),
|
||||
provider_metadata_json: Some(
|
||||
serde_json::json!({
|
||||
"effectiveModel": "grok-4.5",
|
||||
"requestSummary": {"inputItemsCount": 2},
|
||||
"transport": "must-not-overwrite-core",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
attempt_count: 1,
|
||||
upstream_send_state: "sent".to_owned(),
|
||||
downstream_committed_at: None,
|
||||
@@ -409,18 +417,21 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
|
||||
"requestKind": value["data"]["items"][0]["metadata"]["requestKind"],
|
||||
"subagentKind": value["data"]["items"][0]["metadata"]["subagentKind"],
|
||||
"compact": value["data"]["items"][0]["metadata"]["compact"],
|
||||
"transport": value["data"]["items"][0]["metadata"]["transport"],
|
||||
"transportDecisionWaitMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["transportDecisionWaitMs"],
|
||||
"wsConnectMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["wsConnectMs"],
|
||||
"firstReasoningMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstReasoningMs"],
|
||||
"firstTextMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstTextMs"],
|
||||
"firstTokenMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstTokenMs"],
|
||||
"effectiveModel": value["data"]["items"][0]["metadata"]["effectiveModel"],
|
||||
"requestSummary": value["data"]["items"][0]["metadata"]["requestSummary"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"route": "/v1/responses",
|
||||
"authenticationKind": "oauth",
|
||||
"imageInputTokens": 31,
|
||||
"imageOutputTokens": 9,
|
||||
"websocketPool": "reuse",
|
||||
"websocketPool": {"kind": "reuse"},
|
||||
"imageGenerationRequested": true,
|
||||
"imageGenerationSucceeded": true,
|
||||
"requestedModel": "grok-4.5",
|
||||
@@ -432,11 +443,14 @@ async fn usage_route_should_expose_image_and_websocket_facts() {
|
||||
"requestKind": "review",
|
||||
"subagentKind": "worker",
|
||||
"compact": true,
|
||||
"transport": "http_sse",
|
||||
"transportDecisionWaitMs": 7,
|
||||
"wsConnectMs": 11,
|
||||
"firstReasoningMs": 19,
|
||||
"firstTextMs": 23,
|
||||
"firstTokenMs": 19,
|
||||
"effectiveModel": "grok-4.5",
|
||||
"requestSummary": {"inputItemsCount": 2},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::accounting::{CostEstimate, CostSource, Usage};
|
||||
use crate::engine::continuation::{
|
||||
@@ -58,7 +58,7 @@ where
|
||||
) -> Result<ResponseExecutionSession<S>, EngineError> {
|
||||
let request_id = request.id.clone();
|
||||
let client_api_key_ref = request.client_api_key_ref.clone();
|
||||
let request_started_at = request.started_at;
|
||||
let timing_started_at = Instant::now();
|
||||
let deadline = request.deadline_at;
|
||||
let account_state_owner = continuation
|
||||
.as_ref()
|
||||
@@ -77,7 +77,7 @@ where
|
||||
engine: Arc::clone(&self.engine),
|
||||
request_id,
|
||||
client_api_key_ref,
|
||||
request_started_at,
|
||||
timing_started_at,
|
||||
deadline,
|
||||
pending_request: Some(request),
|
||||
request_persisted: false,
|
||||
@@ -155,7 +155,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
engine: Arc<GatewayEngine<S>>,
|
||||
request_id: ModelRequestId,
|
||||
client_api_key_ref: crate::policy::ClientApiKeyId,
|
||||
request_started_at: SystemTime,
|
||||
timing_started_at: Instant,
|
||||
deadline: SystemTime,
|
||||
pending_request: Option<NewModelRequest>,
|
||||
request_persisted: bool,
|
||||
@@ -499,7 +499,8 @@ where
|
||||
.and_then(NonZeroU32::new)
|
||||
.ok_or(EngineError::EmptyRoutingPlan)?;
|
||||
let context = AttemptContext::new(
|
||||
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone()),
|
||||
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
|
||||
.with_timing_started_at(self.timing_started_at),
|
||||
next_attempt,
|
||||
self.deadline,
|
||||
self.plan.account_selection_policy(),
|
||||
@@ -692,8 +693,7 @@ where
|
||||
}
|
||||
|
||||
async fn observe_event(&mut self, event: &GatewayEvent) -> Result<(), EngineError> {
|
||||
let observed_at = SystemTime::now();
|
||||
let elapsed = elapsed_ms(self.request_started_at, observed_at);
|
||||
let elapsed = elapsed_ms(self.timing_started_at);
|
||||
observe_event_timing(&mut self.timings, event, elapsed);
|
||||
if let GatewayEvent::Usage(observed) = event {
|
||||
self.usage.merge(observed);
|
||||
@@ -758,6 +758,18 @@ where
|
||||
if let Some(value) = observed.first_event_ms {
|
||||
self.timings.first_event_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_reasoning_ms {
|
||||
self.timings.first_reasoning_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_text_ms {
|
||||
self.timings.first_text_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.first_token_ms {
|
||||
self.timings.first_token_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.provider_processing_ms {
|
||||
self.timings.provider_processing_ms = Some(value);
|
||||
}
|
||||
current.response_observation = Some(observation);
|
||||
Ok(())
|
||||
}
|
||||
@@ -949,7 +961,7 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
let completed_at = SystemTime::now();
|
||||
self.timings.latency_ms = Some(elapsed_ms(self.request_started_at, completed_at));
|
||||
self.timings.latency_ms = Some(elapsed_ms(self.timing_started_at));
|
||||
let upstream_request_id = self.current.as_ref().and_then(|current| {
|
||||
current
|
||||
.response_observation
|
||||
@@ -974,6 +986,7 @@ where
|
||||
.or(Some(200));
|
||||
let (upstream_transport, http_version, websocket_pool) =
|
||||
self.current_transport_observation();
|
||||
let provider_metadata_json = self.current_provider_metadata_json();
|
||||
self.engine
|
||||
.store()
|
||||
.finalize_model_request(ModelRequestFinalization {
|
||||
@@ -990,6 +1003,7 @@ where
|
||||
upstream_transport,
|
||||
http_version,
|
||||
websocket_pool,
|
||||
provider_metadata_json,
|
||||
error: None,
|
||||
provider_error_code: None,
|
||||
retry_after_ms: None,
|
||||
@@ -1096,7 +1110,7 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
let completed_at = SystemTime::now();
|
||||
self.timings.latency_ms = Some(elapsed_ms(self.request_started_at, completed_at));
|
||||
self.timings.latency_ms = Some(elapsed_ms(self.timing_started_at));
|
||||
let upstream_request_id = self.current.as_ref().and_then(|current| {
|
||||
current
|
||||
.response_observation
|
||||
@@ -1120,6 +1134,7 @@ where
|
||||
.and_then(ProviderResponseObservation::status_code);
|
||||
let (upstream_transport, http_version, websocket_pool) =
|
||||
self.current_transport_observation();
|
||||
let provider_metadata_json = self.current_provider_metadata_json();
|
||||
self.engine
|
||||
.store()
|
||||
.finalize_model_request(ModelRequestFinalization {
|
||||
@@ -1138,6 +1153,7 @@ where
|
||||
upstream_transport,
|
||||
http_version,
|
||||
websocket_pool,
|
||||
provider_metadata_json,
|
||||
error: Some(finalization.error),
|
||||
provider_error_code: finalization.provider_error_code,
|
||||
retry_after_ms: finalization.retry_after_ms,
|
||||
@@ -1170,6 +1186,14 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
fn current_provider_metadata_json(&self) -> Option<String> {
|
||||
self.current
|
||||
.as_ref()
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::provider_metadata)
|
||||
.map(|metadata| metadata.as_json().to_owned())
|
||||
}
|
||||
|
||||
fn image_generation_succeeded(&self) -> Option<bool> {
|
||||
self.image_generation_requested
|
||||
.then(|| self.usage.image_output_tokens.unwrap_or_default() > 0)
|
||||
@@ -1325,13 +1349,18 @@ fn observe_event_timing(timings: &mut ModelRequestTimings, event: &GatewayEvent,
|
||||
timings.first_text_ms.get_or_insert(elapsed_ms);
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
// `response.output_item.added` 会先投影一个空参数的 tool delta;它只是结构帧,
|
||||
// 不能抢在真实工具参数之前成为首个可消费 token。
|
||||
GatewayEvent::ToolCallDelta(delta) if !delta.arguments_delta.is_empty() => {
|
||||
timings.first_token_ms.get_or_insert(elapsed_ms);
|
||||
}
|
||||
GatewayEvent::CalculatedCost(_) | GatewayEvent::ProviderCost(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started_at: SystemTime, observed_at: SystemTime) -> u64 {
|
||||
duration_ms(observed_at.duration_since(started_at).unwrap_or_default())
|
||||
fn elapsed_ms(started_at: Instant) -> u64 {
|
||||
duration_ms(started_at.elapsed())
|
||||
}
|
||||
|
||||
fn duration_ms(duration: Duration) -> u64 {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::net::IpAddr;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::channel::oneshot;
|
||||
@@ -332,17 +332,26 @@ pub enum ContinuationAttempt {
|
||||
pub struct RequestAttemptContext {
|
||||
request_id: ModelRequestId,
|
||||
client_api_key_ref: ClientApiKeyId,
|
||||
timing_started_at: Instant,
|
||||
}
|
||||
|
||||
impl RequestAttemptContext {
|
||||
#[must_use]
|
||||
pub const fn new(request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId) -> Self {
|
||||
pub fn new(request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId) -> Self {
|
||||
Self {
|
||||
request_id,
|
||||
client_api_key_ref,
|
||||
timing_started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖本次请求的单调计时原点。
|
||||
#[must_use]
|
||||
pub fn with_timing_started_at(mut self, timing_started_at: Instant) -> Self {
|
||||
self.timing_started_at = timing_started_at;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_id(&self) -> &ModelRequestId {
|
||||
&self.request_id
|
||||
@@ -352,6 +361,12 @@ impl RequestAttemptContext {
|
||||
pub const fn client_api_key_ref(&self) -> &ClientApiKeyId {
|
||||
&self.client_api_key_ref
|
||||
}
|
||||
|
||||
/// 返回贯穿本次请求的单调计时原点。
|
||||
#[must_use]
|
||||
pub const fn timing_started_at(&self) -> Instant {
|
||||
self.timing_started_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 每次执行可见的 request-local context。
|
||||
@@ -416,6 +431,12 @@ impl AttemptContext {
|
||||
self.request.client_api_key_ref()
|
||||
}
|
||||
|
||||
/// 返回本次请求统一的单调计时原点。
|
||||
#[must_use]
|
||||
pub const fn timing_started_at(&self) -> Instant {
|
||||
self.request.timing_started_at()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn attempt_index(&self) -> NonZeroU32 {
|
||||
self.attempt_index
|
||||
@@ -552,6 +573,8 @@ pub struct ModelRequestFinalization {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;Core 不解释字段。
|
||||
pub provider_metadata_json: Option<String>,
|
||||
pub error: Option<GatewayError>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub retry_after_ms: Option<u64>,
|
||||
|
||||
@@ -273,7 +273,9 @@ impl ProviderStreamAccountFeedback {
|
||||
|
||||
fn report_failure(&mut self, error: &ProviderError) {
|
||||
if self.reported
|
||||
|| error.send_state() == UpstreamSendState::NotSent
|
||||
// `Ambiguous` 仍须关闭重放边界,但不能证明失败由账号造成;将它计入
|
||||
// 账号评分会把传输不确定性错误归因给账号。
|
||||
|| error.send_state() != UpstreamSendState::Sent
|
||||
|| matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::Cancelled | ProviderErrorKind::ProcessTerminated
|
||||
|
||||
@@ -398,6 +398,46 @@ pub struct ProviderResponseTimings {
|
||||
pub connect_ms: Option<u64>,
|
||||
pub headers_ms: Option<u64>,
|
||||
pub first_event_ms: Option<u64>,
|
||||
pub first_reasoning_ms: Option<u64>,
|
||||
pub first_text_ms: Option<u64>,
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub provider_processing_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Provider 已筛选的响应观测 JSON。
|
||||
///
|
||||
/// Core 只验证它是有界 JSON object,并在请求终态原样交给存储层;字段语义、
|
||||
/// 脱敏和版本演进均由所属 Provider 负责。这样 Provider 专有协议不会渗入路由
|
||||
/// 或管理领域。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResponseMetadata(String);
|
||||
|
||||
impl ProviderResponseMetadata {
|
||||
const MAX_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// 从 Provider 已筛选的 JSON object 创建观测快照。
|
||||
#[must_use]
|
||||
pub fn new(json: String) -> Option<Self> {
|
||||
if json.len() > Self::MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<Value>(&json)
|
||||
.ok()
|
||||
.filter(Value::is_object)
|
||||
.map(|_| Self(json))
|
||||
}
|
||||
|
||||
/// 返回不透明的 JSON 文本,供持久化 adapter 原样验证和写入。
|
||||
#[must_use]
|
||||
pub fn as_json(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderResponseMetadata {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("ProviderResponseMetadata(<provider-owned>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 已筛选、可由协议 adapter 再次按白名单表达的响应头。
|
||||
@@ -451,6 +491,7 @@ pub struct ProviderResponseObservation {
|
||||
request_id: Option<SafeUpstreamValue>,
|
||||
timings: ProviderResponseTimings,
|
||||
client_headers: Vec<ProviderResponseHeader>,
|
||||
provider_metadata: Option<ProviderResponseMetadata>,
|
||||
}
|
||||
|
||||
impl ProviderResponseObservation {
|
||||
@@ -464,6 +505,7 @@ impl ProviderResponseObservation {
|
||||
request_id: None,
|
||||
timings: ProviderResponseTimings::default(),
|
||||
client_headers: Vec::new(),
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,6 +545,13 @@ impl ProviderResponseObservation {
|
||||
self
|
||||
}
|
||||
|
||||
/// 附加由 Provider 自己定义且已筛选的响应观测快照。
|
||||
#[must_use]
|
||||
pub fn with_provider_metadata(mut self, metadata: ProviderResponseMetadata) -> Self {
|
||||
self.provider_metadata = Some(metadata);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn transport(&self) -> &UpstreamTransport {
|
||||
&self.transport
|
||||
@@ -537,6 +586,12 @@ impl ProviderResponseObservation {
|
||||
pub fn client_headers(&self) -> &[ProviderResponseHeader] {
|
||||
&self.client_headers
|
||||
}
|
||||
|
||||
/// 返回 Provider 专有的安全观测 JSON;Core 不读取其字段。
|
||||
#[must_use]
|
||||
pub const fn provider_metadata(&self) -> Option<&ProviderResponseMetadata> {
|
||||
self.provider_metadata.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket 请求使用新连接或复用池中连接的事实。
|
||||
|
||||
@@ -29,8 +29,9 @@ use gateway_core::error::{
|
||||
ContinuationFailure, ProviderError, ProviderErrorKind, SafeUpstreamValue, StoreError,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseObservation,
|
||||
ProviderResponseTimings, ResponseMeta, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
ContentItem, ContentKind, GatewayEvent, ProtocolWireEvent, ProviderEvent,
|
||||
ProviderResponseObservation, ProviderResponseTimings, ResponseMeta, ToolCallDelta,
|
||||
UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::operation::{
|
||||
EmbedRequest, GenerateRequest, Operation, ProtocolPayload, ProviderSessionState, RetrySafety,
|
||||
@@ -67,6 +68,11 @@ struct FinalState {
|
||||
connect_ms: Option<u64>,
|
||||
headers_ms: Option<u64>,
|
||||
first_event_ms: Option<u64>,
|
||||
first_reasoning_ms: Option<u64>,
|
||||
first_text_ms: Option<u64>,
|
||||
first_token_ms: Option<u64>,
|
||||
provider_processing_ms: Option<u64>,
|
||||
provider_metadata_json: Option<String>,
|
||||
cost_source: CostSource,
|
||||
cost_ticks: Option<u128>,
|
||||
}
|
||||
@@ -191,6 +197,11 @@ impl ExecutionStore for FakeStore {
|
||||
connect_ms: finalization.timings.connect_ms,
|
||||
headers_ms: finalization.timings.headers_ms,
|
||||
first_event_ms: finalization.timings.first_event_ms,
|
||||
first_reasoning_ms: finalization.timings.first_reasoning_ms,
|
||||
first_text_ms: finalization.timings.first_text_ms,
|
||||
first_token_ms: finalization.timings.first_token_ms,
|
||||
provider_processing_ms: finalization.timings.provider_processing_ms,
|
||||
provider_metadata_json: finalization.provider_metadata_json,
|
||||
cost_source: finalization.cost.source(),
|
||||
cost_ticks: finalization
|
||||
.cost
|
||||
@@ -632,6 +643,7 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
connect_ms: Some(11),
|
||||
headers_ms: Some(13),
|
||||
first_event_ms: Some(17),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let mut items = vec![Ok(ProviderEvent::observation(observation))];
|
||||
items.extend(
|
||||
@@ -673,6 +685,115 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
assert_eq!(finalization.first_event_ms, Some(17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_observation_should_persist_provider_metadata_and_output_timings() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let metadata = gateway_core::event::ProviderResponseMetadata::new(
|
||||
serde_json::json!({"effectiveModel": "gpt-test", "firstTokenMs": 23}).to_string(),
|
||||
)
|
||||
.expect("provider metadata object");
|
||||
let observation = ProviderResponseObservation::new(
|
||||
UpstreamTransport::new("websocket").expect("actual transport"),
|
||||
)
|
||||
.with_timings(ProviderResponseTimings {
|
||||
first_event_ms: Some(11),
|
||||
first_reasoning_ms: Some(17),
|
||||
first_text_ms: Some(19),
|
||||
first_token_ms: Some(17),
|
||||
provider_processing_ms: Some(5),
|
||||
..ProviderResponseTimings::default()
|
||||
})
|
||||
.with_provider_metadata(metadata);
|
||||
let mut items = vec![Ok(ProviderEvent::observation(observation))];
|
||||
items.extend(
|
||||
complete_stream(Some(21))
|
||||
.into_iter()
|
||||
.map(|event| event.map(ProviderEvent::canonical)),
|
||||
);
|
||||
let (coordinator, store, _) = coordinator(vec![Script::ObservedStream {
|
||||
account_id: "acct_observed",
|
||||
items,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
block_on(session.collect_uncommitted()).expect("collect response");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let state = store.state.lock().expect("store lock");
|
||||
let finalization = &state.finalizations[0];
|
||||
assert_eq!(finalization.first_event_ms, Some(11));
|
||||
assert_eq!(finalization.first_reasoning_ms, Some(17));
|
||||
assert_eq!(finalization.first_text_ms, Some(19));
|
||||
assert_eq!(finalization.first_token_ms, Some(17));
|
||||
assert_eq!(finalization.provider_processing_ms, Some(5));
|
||||
assert_eq!(
|
||||
finalization.provider_metadata_json.as_deref(),
|
||||
Some("{\"effectiveModel\":\"gpt-test\",\"firstTokenMs\":23}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tool_call_delta_should_not_preempt_provider_first_token_timing() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
let route_plan = plan(&operation);
|
||||
let transport = UpstreamTransport::new("websocket").expect("actual transport");
|
||||
let initial_observation = ProviderResponseObservation::new(transport.clone());
|
||||
let output_observation =
|
||||
ProviderResponseObservation::new(transport).with_timings(ProviderResponseTimings {
|
||||
first_token_ms: Some(37),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let items = vec![
|
||||
Ok(ProviderEvent::observation(initial_observation)),
|
||||
Ok(ProviderEvent::canonical(GatewayEvent::Started(
|
||||
ResponseMeta::new("response-tool", "gpt-5"),
|
||||
))),
|
||||
Ok(ProviderEvent::canonical(GatewayEvent::ContentAdded(
|
||||
ContentItem::new(0, ContentKind::ToolCall),
|
||||
))),
|
||||
Ok(ProviderEvent::canonical(GatewayEvent::ToolCallDelta(
|
||||
ToolCallDelta {
|
||||
content_index: 0,
|
||||
call_id: "call-tool".to_owned(),
|
||||
name: Some("apply_patch".to_owned()),
|
||||
arguments_delta: String::new(),
|
||||
},
|
||||
))),
|
||||
Ok(ProviderEvent::observation(output_observation)),
|
||||
Ok(ProviderEvent::canonical(GatewayEvent::Completed(
|
||||
ResponseMeta::new("response-tool", "gpt-5"),
|
||||
))),
|
||||
];
|
||||
let (coordinator, store, _) = coordinator(vec![Script::ObservedStream {
|
||||
account_id: "acct_tool",
|
||||
items,
|
||||
}]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
block_on(session.collect_uncommitted()).expect("collect response");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.finalizations[0].first_token_ms, Some(37));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_wire_event_before_response_identity_is_discarded_with_retried_attempt() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
@@ -734,6 +855,7 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
connect_ms: Some(11),
|
||||
headers_ms: Some(13),
|
||||
first_event_ms: Some(987_654),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let second_observation = ProviderResponseObservation::new(
|
||||
UpstreamTransport::new("http_sse").expect("second transport"),
|
||||
|
||||
@@ -203,13 +203,15 @@ fn provider_stream_should_not_sequence_gate_canonical_observation_attached_to_wi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_stream_should_report_sent_failure_but_ignore_not_sent_failure() {
|
||||
fn provider_stream_should_report_confirmed_sent_failure_but_ignore_unconfirmed_failure() {
|
||||
let feedback = Arc::new(AccountFeedbackStats::default());
|
||||
let provider = ProviderKind::new("xai").expect("valid provider");
|
||||
let sent_account = ProviderAccountId::new("acct_stream_sent").expect("account");
|
||||
let ambiguous_account = ProviderAccountId::new("acct_stream_ambiguous").expect("account");
|
||||
let not_sent_account = ProviderAccountId::new("acct_stream_not_sent").expect("account");
|
||||
for (account, send_state) in [
|
||||
(sent_account.clone(), UpstreamSendState::Sent),
|
||||
(ambiguous_account.clone(), UpstreamSendState::Ambiguous),
|
||||
(not_sent_account.clone(), UpstreamSendState::NotSent),
|
||||
] {
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
@@ -241,6 +243,10 @@ fn provider_stream_should_report_sent_failure_but_ignore_not_sent_failure() {
|
||||
feedback.scheduling_signals(&provider, &sent_account).0,
|
||||
Some(2_000)
|
||||
);
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, &ambiguous_account),
|
||||
(None, None)
|
||||
);
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, ¬_sent_account),
|
||||
(None, None)
|
||||
|
||||
@@ -49,7 +49,8 @@ async fn restart_should_request_process_restart_inside_docker() {
|
||||
#[tokio::test]
|
||||
async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.write_executable("#!/bin/sh\nexit 0\n");
|
||||
let replacement_marker = fixture.root.path().join("replacement-ran");
|
||||
fixture.write_executable("#!/bin/sh\n: > \"${0%/*}/replacement-ran\"\n");
|
||||
let mut config = fixture.config("http://127.0.0.1:1/repos");
|
||||
config.self_restart_enabled = true;
|
||||
let shutdown = CancellationToken::new();
|
||||
@@ -66,6 +67,13 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() {
|
||||
tokio::time::timeout(Duration::from_secs(2), shutdown.cancelled())
|
||||
.await
|
||||
.expect("shutdown requested after spawn");
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !replacement_marker.is_file() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("replacement executed before fixture cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -223,6 +224,7 @@ pub struct ModelRequestFinalization {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub provider_metadata_json: Option<Value>,
|
||||
pub error_kind: Option<String>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
@@ -274,6 +276,13 @@ impl ModelRequestFinalization {
|
||||
{
|
||||
return Err(invalid("websocket pool must be new or reuse"));
|
||||
}
|
||||
if self
|
||||
.provider_metadata_json
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| !metadata.is_object())
|
||||
{
|
||||
return Err(invalid("provider observation must be a JSON object"));
|
||||
}
|
||||
self.timings.validate()
|
||||
}
|
||||
}
|
||||
@@ -498,7 +507,8 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
first_text_ms = $32, first_token_ms = $33, provider_processing_ms = $34,
|
||||
latency_ms = $35, completed_at = $36,
|
||||
upstream_transport = coalesce($37, upstream_transport),
|
||||
http_version = coalesce($38, http_version), websocket_pool = $39
|
||||
http_version = coalesce($38, http_version), websocket_pool = $39,
|
||||
provider_observation_json = $40
|
||||
where id = $1 and outcome = 'running'",
|
||||
)
|
||||
.bind(&finalization.model_request_id)
|
||||
@@ -585,6 +595,7 @@ impl ModelRequestRepository for PgExecutionStore {
|
||||
.bind(finalization.upstream_transport)
|
||||
.bind(finalization.http_version)
|
||||
.bind(finalization.websocket_pool)
|
||||
.bind(finalization.provider_metadata_json.map(sqlx::types::Json))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("finalize model request"))?;
|
||||
@@ -769,6 +780,18 @@ impl ExecutionStore for PgExecutionStore {
|
||||
&self,
|
||||
finalization: CoreModelRequestFinalization,
|
||||
) -> Result<(), CoreStoreError> {
|
||||
let provider_metadata_json = finalization
|
||||
.provider_metadata_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str::<Value>)
|
||||
.transpose()
|
||||
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
|
||||
if provider_metadata_json
|
||||
.as_ref()
|
||||
.is_some_and(|metadata| !metadata.is_object())
|
||||
{
|
||||
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
|
||||
}
|
||||
let (cost_source, cost_amount, cost_currency) = match finalization.cost.total() {
|
||||
Some(total) => (
|
||||
cost_source_from_core(finalization.cost.source()),
|
||||
@@ -809,6 +832,7 @@ impl ExecutionStore for PgExecutionStore {
|
||||
upstream_transport: finalization.upstream_transport,
|
||||
http_version: finalization.http_version,
|
||||
websocket_pool: finalization.websocket_pool,
|
||||
provider_metadata_json,
|
||||
error_kind,
|
||||
provider_error_code: finalization.provider_error_code,
|
||||
error_message,
|
||||
|
||||
@@ -364,6 +364,19 @@ pub struct RequestMetricPoint {
|
||||
pub costs: Vec<CurrencyCostTotal>,
|
||||
}
|
||||
|
||||
/// 已完整交付且由 Provider 计算费用的请求事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalculatedUsageBillingFact {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub provider_kind: String,
|
||||
pub upstream_model_id: String,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub total: CurrencyCostTotal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ProviderAccountMetrics {
|
||||
pub total: u64,
|
||||
@@ -509,6 +522,7 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub provider_metadata_json: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub upstream_send_state: String,
|
||||
pub downstream_committed_at: Option<DateTime<Utc>>,
|
||||
@@ -679,6 +693,11 @@ pub trait ObservabilityRepository: Send + Sync {
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
) -> StoreResult<Vec<RequestMetricPoint>>;
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
) -> StoreResult<Vec<CalculatedUsageBillingFact>>;
|
||||
async fn provider_account_usage(
|
||||
&self,
|
||||
query: ProviderAccountUsageQuery,
|
||||
@@ -793,6 +812,14 @@ impl ObservabilityRepository for PgObservabilityRepository {
|
||||
request_metric_series(&self.pool, range, &filter).await
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
) -> StoreResult<Vec<CalculatedUsageBillingFact>> {
|
||||
calculated_usage_billing_facts(&self.pool, range, &filter).await
|
||||
}
|
||||
|
||||
async fn provider_account_usage(
|
||||
&self,
|
||||
query: ProviderAccountUsageQuery,
|
||||
@@ -919,6 +946,20 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: admin_observability::TimeRange,
|
||||
filter: admin_observability::UsageFilter,
|
||||
) -> AdminStoreResult<Vec<admin_observability::UsageCalculatedBillingFact>> {
|
||||
self.repository
|
||||
.usage_calculated_billing_facts(store_range(range)?, store_usage_filter(filter))
|
||||
.await
|
||||
.map_err(observability_error)?
|
||||
.into_iter()
|
||||
.map(admin_calculated_usage_billing_fact)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn list_usage_records(
|
||||
&self,
|
||||
query: admin_observability::UsageQuery,
|
||||
@@ -1330,6 +1371,24 @@ fn admin_request_metric_point(
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_calculated_usage_billing_fact(
|
||||
fact: CalculatedUsageBillingFact,
|
||||
) -> AdminStoreResult<admin_observability::UsageCalculatedBillingFact> {
|
||||
Ok(admin_observability::UsageCalculatedBillingFact {
|
||||
bucket_start: fact.bucket_start,
|
||||
provider_kind: fact.provider_kind,
|
||||
upstream_model_id: fact.upstream_model_id,
|
||||
input_tokens: fact.input_tokens,
|
||||
output_tokens: fact.output_tokens,
|
||||
cached_tokens: fact.cached_tokens,
|
||||
cache_write_tokens: fact.cache_write_tokens,
|
||||
total: admin_observability::CurrencyCost {
|
||||
currency: fact.total.currency,
|
||||
amount: admin_decimal_amount(fact.total.amount)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const fn admin_granularity(
|
||||
granularity: ObservationGranularity,
|
||||
) -> admin_observability::Granularity {
|
||||
@@ -1394,6 +1453,7 @@ fn admin_usage_record(record: UsageRecord) -> AdminStoreResult<admin_observabili
|
||||
upstream_transport: record.upstream_transport,
|
||||
http_version: record.http_version,
|
||||
websocket_pool: record.websocket_pool,
|
||||
provider_metadata_json: record.provider_metadata_json,
|
||||
attempt_count: record.attempt_count,
|
||||
upstream_send_state: record.upstream_send_state,
|
||||
downstream_committed_at: record.downstream_committed_at,
|
||||
@@ -1629,6 +1689,7 @@ async fn request_metrics(
|
||||
query.push_bind(range.start);
|
||||
query.push(" and mr.started_at < ");
|
||||
query.push_bind(range.end);
|
||||
push_completed_usage_fact_filter(&mut query, "mr");
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
let row = query
|
||||
.build()
|
||||
@@ -1695,6 +1756,7 @@ async fn request_metric_series(
|
||||
query.push_bind(range.start);
|
||||
query.push(" and mr.started_at < ");
|
||||
query.push_bind(range.end);
|
||||
push_completed_usage_fact_filter(&mut query, "mr");
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
query.push(" group by bucket_start order by bucket_start");
|
||||
let rows = query
|
||||
@@ -1726,6 +1788,44 @@ async fn request_metric_series(
|
||||
fill_metric_gaps(range, granularity, points)
|
||||
}
|
||||
|
||||
async fn calculated_usage_billing_facts(
|
||||
pool: &PgPool,
|
||||
range: ObservabilityRange,
|
||||
filter: &UsageRecordFilter,
|
||||
) -> StoreResult<Vec<CalculatedUsageBillingFact>> {
|
||||
filter.validate()?;
|
||||
let granularity = granularity_for(range);
|
||||
let mut query = QueryBuilder::<Postgres>::new("select date_bin(");
|
||||
query.push_bind(granularity.sql_interval());
|
||||
query.push(
|
||||
"::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start,
|
||||
mr.provider_kind, mr.upstream_model_id,
|
||||
mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens,
|
||||
mr.cost_currency, mr.cost_amount::text as amount
|
||||
from model_requests mr where mr.started_at >= ",
|
||||
);
|
||||
query.push_bind(range.start);
|
||||
query.push(" and mr.started_at < ");
|
||||
query.push_bind(range.end);
|
||||
query.push(
|
||||
" and mr.cost_source = 'calculated'
|
||||
and mr.cost_amount is not null and mr.cost_currency is not null
|
||||
and nullif(mr.provider_kind, '') is not null
|
||||
and nullif(mr.upstream_model_id, '') is not null",
|
||||
);
|
||||
push_completed_usage_fact_filter(&mut query, "mr");
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
query.push(" order by bucket_start, mr.id");
|
||||
query
|
||||
.build()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("load calculated usage billing facts"))?
|
||||
.iter()
|
||||
.map(calculated_usage_billing_fact_from_row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn request_costs_by_bucket(
|
||||
pool: &PgPool,
|
||||
range: ObservabilityRange,
|
||||
@@ -1744,6 +1844,7 @@ async fn request_costs_by_bucket(
|
||||
query.push(" and mr.started_at < ");
|
||||
query.push_bind(range.end);
|
||||
query.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
|
||||
push_completed_usage_fact_filter(&mut query, "mr");
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
query.push(" group by bucket_start, mr.cost_currency order by bucket_start, mr.cost_currency");
|
||||
let rows = query
|
||||
@@ -1776,7 +1877,12 @@ async fn attempt_metrics(
|
||||
query.push_bind(range.end);
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
query.push(
|
||||
"), failures as (
|
||||
"), usage_facts as (
|
||||
select * from selected_requests
|
||||
where outcome = 'succeeded'
|
||||
and downstream_committed_at is not null
|
||||
and client_status_code between 200 and 399
|
||||
), failures as (
|
||||
select coalesce(oe.occurrence_count, 1)::bigint as occurrences,
|
||||
oe.failure_kind, oe.status_code
|
||||
from ops_events oe
|
||||
@@ -1809,13 +1915,13 @@ async fn attempt_metrics(
|
||||
coalesce((select sum(occurrences) from failures
|
||||
where status_code between 500 and 599), 0)::bigint
|
||||
as provider_5xx_count,
|
||||
coalesce((select count(*) from selected_requests
|
||||
coalesce((select count(*) from usage_facts
|
||||
where cost_source = 'provider_reported'), 0)::bigint
|
||||
as provider_reported_count,
|
||||
coalesce((select count(*) from selected_requests
|
||||
coalesce((select count(*) from usage_facts
|
||||
where cost_source = 'calculated'), 0)::bigint
|
||||
as calculated_count,
|
||||
coalesce((select count(*) from selected_requests
|
||||
coalesce((select count(*) from usage_facts
|
||||
where cost_source = 'unavailable'), 0)::bigint
|
||||
as unavailable_count",
|
||||
);
|
||||
@@ -1851,6 +1957,7 @@ async fn request_costs(
|
||||
query.push(" and mr.started_at < ");
|
||||
query.push_bind(range.end);
|
||||
query.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
|
||||
push_completed_usage_fact_filter(&mut query, "mr");
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
query.push(" group by mr.cost_currency order by mr.cost_currency");
|
||||
query
|
||||
@@ -1938,6 +2045,7 @@ async fn provider_observations(
|
||||
query.push_bind(range.start);
|
||||
query.push(" and mr.started_at < ");
|
||||
query.push_bind(range.end);
|
||||
push_completed_usage_fact_filter(&mut query, "mr");
|
||||
push_usage_filter(&mut query, filter, "mr");
|
||||
query.push(" group by coalesce(mr.provider_kind, 'unrouted') order by request_count desc");
|
||||
let rows = query
|
||||
@@ -2129,6 +2237,15 @@ fn push_usage_filter(query: &mut QueryBuilder<Postgres>, filter: &UsageRecordFil
|
||||
}
|
||||
}
|
||||
|
||||
/// 只把已完整交付给客户端的成功响应投影为使用事实。
|
||||
///
|
||||
/// `model_requests` 同时承担执行审计:包括上游已发送、但尚未收到首个事件就断开的
|
||||
/// WebSocket 请求。这些失败仍必须留给 Ops Errors 和调度健康度分析,不能混入用量、
|
||||
/// 成本、账号使用次数或请求明细。
|
||||
fn push_completed_usage_fact_filter(query: &mut QueryBuilder<Postgres>, alias: &str) {
|
||||
query.push(format!(" and {alias}.outcome = 'succeeded' and {alias}.downstream_committed_at is not null and {alias}.client_status_code between 200 and 399"));
|
||||
}
|
||||
|
||||
async fn provider_account_usage(
|
||||
pool: &PgPool,
|
||||
query: ProviderAccountUsageQuery,
|
||||
@@ -2171,6 +2288,7 @@ async fn provider_account_usage(
|
||||
statement.push_bind(query.range.start);
|
||||
statement.push(" and mr.started_at < ");
|
||||
statement.push_bind(query.range.end);
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
if let Some(account_ids) = &query.account_ids {
|
||||
statement.push(" where pa.id = any(");
|
||||
statement.push_bind(account_ids.clone());
|
||||
@@ -2263,11 +2381,14 @@ async fn provider_account_request_buckets(
|
||||
"select provider_account_ref,
|
||||
floor(extract(epoch from (started_at - $1)) / 3600)::bigint as bucket_index,
|
||||
count(*)::bigint as request_count
|
||||
from model_requests
|
||||
where provider_account_ref = any($2::text[])
|
||||
and started_at >= $1 and started_at < $3
|
||||
group by provider_account_ref, bucket_index
|
||||
order by provider_account_ref, bucket_index",
|
||||
from model_requests mr
|
||||
where mr.provider_account_ref = any($2::text[])
|
||||
and mr.started_at >= $1 and mr.started_at < $3
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
group by mr.provider_account_ref, bucket_index
|
||||
order by mr.provider_account_ref, bucket_index",
|
||||
)
|
||||
.bind(range.start)
|
||||
.bind(account_ids)
|
||||
@@ -2317,13 +2438,16 @@ async fn account_costs(
|
||||
account_ids: &[String],
|
||||
) -> StoreResult<HashMap<String, Vec<CurrencyCostTotal>>> {
|
||||
let rows = sqlx::query(
|
||||
"select provider_account_ref, cost_currency, sum(cost_amount)::text as amount
|
||||
from model_requests
|
||||
where provider_account_ref = any($1::text[])
|
||||
and started_at >= $2 and started_at < $3
|
||||
and cost_amount is not null and cost_currency is not null
|
||||
group by provider_account_ref, cost_currency
|
||||
order by provider_account_ref, cost_currency",
|
||||
"select mr.provider_account_ref, mr.cost_currency, sum(mr.cost_amount)::text as amount
|
||||
from model_requests mr
|
||||
where mr.provider_account_ref = any($1::text[])
|
||||
and mr.started_at >= $2 and mr.started_at < $3
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
and mr.cost_amount is not null and mr.cost_currency is not null
|
||||
group by mr.provider_account_ref, mr.cost_currency
|
||||
order by mr.provider_account_ref, mr.cost_currency",
|
||||
)
|
||||
.bind(account_ids)
|
||||
.bind(range.start)
|
||||
@@ -2347,34 +2471,37 @@ async fn account_model_rows(
|
||||
account_ids: &[String],
|
||||
) -> StoreResult<Vec<sqlx::postgres::PgRow>> {
|
||||
sqlx::query(
|
||||
"select provider_account_ref,
|
||||
coalesce(upstream_model_id, requested_model_id) as model,
|
||||
"select mr.provider_account_ref,
|
||||
coalesce(mr.upstream_model_id, mr.requested_model_id) as model,
|
||||
count(*)::bigint as request_count,
|
||||
count(*) filter (where outcome = 'succeeded')::bigint as success_count,
|
||||
sum(input_tokens)::bigint as input_tokens,
|
||||
sum(output_tokens)::bigint as output_tokens,
|
||||
sum(cached_tokens)::bigint as cached_tokens,
|
||||
sum(cache_write_tokens)::bigint as cache_write_tokens,
|
||||
sum(reasoning_tokens)::bigint as reasoning_tokens,
|
||||
sum(image_input_tokens)::bigint as image_input_tokens,
|
||||
sum(image_output_tokens)::bigint as image_output_tokens,
|
||||
count(*) filter (where image_generation_succeeded is true)::bigint
|
||||
count(*) filter (where mr.outcome = 'succeeded')::bigint as success_count,
|
||||
sum(mr.input_tokens)::bigint as input_tokens,
|
||||
sum(mr.output_tokens)::bigint as output_tokens,
|
||||
sum(mr.cached_tokens)::bigint as cached_tokens,
|
||||
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
|
||||
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
|
||||
sum(mr.image_input_tokens)::bigint as image_input_tokens,
|
||||
sum(mr.image_output_tokens)::bigint as image_output_tokens,
|
||||
count(*) filter (where mr.image_generation_succeeded is true)::bigint
|
||||
as image_request_count,
|
||||
count(*) filter (where image_generation_succeeded is false)::bigint
|
||||
count(*) filter (where mr.image_generation_succeeded is false)::bigint
|
||||
as image_request_failed_count,
|
||||
sum(total_tokens)::bigint as total_tokens,
|
||||
count(*) filter (where cost_source = 'provider_reported')::bigint
|
||||
sum(mr.total_tokens)::bigint as total_tokens,
|
||||
count(*) filter (where mr.cost_source = 'provider_reported')::bigint
|
||||
as provider_reported_count,
|
||||
count(*) filter (where cost_source = 'calculated')::bigint
|
||||
count(*) filter (where mr.cost_source = 'calculated')::bigint
|
||||
as calculated_count,
|
||||
count(*) filter (where cost_source = 'unavailable')::bigint
|
||||
count(*) filter (where mr.cost_source = 'unavailable')::bigint
|
||||
as unavailable_count,
|
||||
max(started_at) as last_used_at
|
||||
from model_requests
|
||||
where provider_account_ref = any($1::text[])
|
||||
and started_at >= $2 and started_at < $3
|
||||
group by provider_account_ref, coalesce(upstream_model_id, requested_model_id)
|
||||
order by provider_account_ref, request_count desc, model",
|
||||
max(mr.started_at) as last_used_at
|
||||
from model_requests mr
|
||||
where mr.provider_account_ref = any($1::text[])
|
||||
and mr.started_at >= $2 and mr.started_at < $3
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id)
|
||||
order by mr.provider_account_ref, request_count desc, model",
|
||||
)
|
||||
.bind(account_ids)
|
||||
.bind(range.start)
|
||||
@@ -2390,16 +2517,19 @@ async fn account_model_costs(
|
||||
account_ids: &[String],
|
||||
) -> StoreResult<HashMap<(String, String), Vec<CurrencyCostTotal>>> {
|
||||
let rows = sqlx::query(
|
||||
"select provider_account_ref,
|
||||
coalesce(upstream_model_id, requested_model_id) as model,
|
||||
cost_currency, sum(cost_amount)::text as amount
|
||||
from model_requests
|
||||
where provider_account_ref = any($1::text[])
|
||||
and started_at >= $2 and started_at < $3
|
||||
and cost_amount is not null and cost_currency is not null
|
||||
group by provider_account_ref, coalesce(upstream_model_id, requested_model_id),
|
||||
cost_currency
|
||||
order by provider_account_ref, model, cost_currency",
|
||||
"select mr.provider_account_ref,
|
||||
coalesce(mr.upstream_model_id, mr.requested_model_id) as model,
|
||||
mr.cost_currency, sum(mr.cost_amount)::text as amount
|
||||
from model_requests mr
|
||||
where mr.provider_account_ref = any($1::text[])
|
||||
and mr.started_at >= $2 and mr.started_at < $3
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
and mr.cost_amount is not null and mr.cost_currency is not null
|
||||
group by mr.provider_account_ref, coalesce(mr.upstream_model_id, mr.requested_model_id),
|
||||
mr.cost_currency
|
||||
order by mr.provider_account_ref, model, mr.cost_currency",
|
||||
)
|
||||
.bind(account_ids)
|
||||
.bind(range.start)
|
||||
@@ -2456,6 +2586,7 @@ const USAGE_RECORD_SELECT: &str =
|
||||
pa.name as provider_account_name, pa.email as provider_account_email,
|
||||
pa.authentication_kind as provider_account_authentication_kind,
|
||||
mr.upstream_model_id, mr.upstream_transport, mr.http_version, mr.websocket_pool,
|
||||
mr.provider_observation_json,
|
||||
mr.attempt_count, mr.upstream_send_state, mr.downstream_committed_at,
|
||||
mr.outcome, mr.client_status_code, mr.upstream_status_code,
|
||||
mr.client_response_id, mr.upstream_request_id, mr.upstream_response_id,
|
||||
@@ -2483,6 +2614,7 @@ async fn list_usage_records(
|
||||
statement.push_bind(query.range.start);
|
||||
statement.push(" and mr.started_at < ");
|
||||
statement.push_bind(query.range.end);
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
push_usage_filter(&mut statement, &query.filter, "mr");
|
||||
if let Some(cursor) = &query.cursor {
|
||||
statement.push(" and (mr.started_at, mr.id) < (");
|
||||
@@ -2540,6 +2672,7 @@ async fn count_usage_records(
|
||||
statement.push_bind(range.start);
|
||||
statement.push(" and mr.started_at < ");
|
||||
statement.push_bind(range.end);
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
push_usage_filter(&mut statement, filter, "mr");
|
||||
let total = statement
|
||||
.build_query_scalar::<i64>()
|
||||
@@ -2555,6 +2688,7 @@ async fn usage_record_detail(pool: &PgPool, request_id: &str) -> StoreResult<Usa
|
||||
let mut statement = QueryBuilder::<Postgres>::new(USAGE_RECORD_SELECT);
|
||||
statement.push(" where mr.id = ");
|
||||
statement.push_bind(request_id.to_owned());
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
let row = statement
|
||||
.build()
|
||||
.fetch_optional(pool)
|
||||
@@ -2686,6 +2820,7 @@ async fn usage_diagnostics(
|
||||
statement.push_bind(range.start);
|
||||
statement.push(" and mr.started_at < ");
|
||||
statement.push_bind(range.end);
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
push_usage_filter(&mut statement, filter, "mr");
|
||||
statement.push(" group by dimension_name order by request_count desc, dimension_name limit ");
|
||||
statement.push_bind(DIAGNOSTIC_LIMIT);
|
||||
@@ -2740,6 +2875,7 @@ async fn diagnostic_costs(
|
||||
statement.push(" and mr.started_at < ");
|
||||
statement.push_bind(range.end);
|
||||
statement.push(" and mr.cost_amount is not null and mr.cost_currency is not null");
|
||||
push_completed_usage_fact_filter(&mut statement, "mr");
|
||||
push_usage_filter(&mut statement, filter, "mr");
|
||||
statement.push(
|
||||
" group by dimension_name, mr.cost_currency order by dimension_name, mr.cost_currency",
|
||||
@@ -2943,6 +3079,10 @@ fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<UsageRecord
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
http_version: get(row, "http_version")?,
|
||||
websocket_pool: get(row, "websocket_pool")?,
|
||||
provider_metadata_json: get::<Option<serde_json::Value>>(row, "provider_observation_json")?
|
||||
.map(|value| serde_json::to_string(&value))
|
||||
.transpose()
|
||||
.map_err(|_| postgres_unavailable("encode provider observation"))?,
|
||||
attempt_count: to_u32(get(row, "attempt_count")?)?,
|
||||
upstream_send_state: get(row, "upstream_send_state")?,
|
||||
downstream_committed_at: get(row, "downstream_committed_at")?,
|
||||
@@ -3026,6 +3166,21 @@ fn cost_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<CurrencyCostTotal>
|
||||
})
|
||||
}
|
||||
|
||||
fn calculated_usage_billing_fact_from_row(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> StoreResult<CalculatedUsageBillingFact> {
|
||||
Ok(CalculatedUsageBillingFact {
|
||||
bucket_start: get(row, "bucket_start")?,
|
||||
provider_kind: get(row, "provider_kind")?,
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
input_tokens: optional_unsigned(row, "input_tokens")?,
|
||||
output_tokens: optional_unsigned(row, "output_tokens")?,
|
||||
cached_tokens: optional_unsigned(row, "cached_tokens")?,
|
||||
cache_write_tokens: optional_unsigned(row, "cache_write_tokens")?,
|
||||
total: cost_from_row(row)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_decimal(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
column: &'static str,
|
||||
|
||||
@@ -16,8 +16,8 @@ use gateway_admin::{
|
||||
AccountListQuery as AdminAccountListQuery, AccountModelUsage, AccountPage,
|
||||
AccountRecord, AccountRequestBucket, AccountSort as AdminAccountSort,
|
||||
AccountSortField as AdminAccountSortField, AccountStatus as AdminAccountStatus,
|
||||
AccountSummary, AccountUsage, DeleteAccounts, SetAccountEnabled,
|
||||
SortDirection as AdminSortDirection,
|
||||
AccountSummary, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
|
||||
DeleteAccounts, SetAccountEnabled, SortDirection as AdminSortDirection,
|
||||
},
|
||||
observability::{
|
||||
CostCoverage as AdminCostCoverage, DecimalAmount as AdminDecimalAmount, TimeRange,
|
||||
@@ -64,6 +64,48 @@ const QUOTA_MAX_BYTES: usize = 128 * 1024;
|
||||
const MAX_ADMIN_IMPORT_BATCH: usize = 200;
|
||||
const ADMIN_USAGE_CHUNK_SIZE: usize = 200;
|
||||
|
||||
const ACCOUNT_USAGE_BY_WINDOWS_SQL: &str = "with requested_windows as (
|
||||
select *
|
||||
from unnest($1::text[], $2::text[], $3::timestamptz[], $4::timestamptz[])
|
||||
as requested(account_id, window_key, window_start, window_end)
|
||||
)
|
||||
select requested.account_id,
|
||||
requested.window_key,
|
||||
count(mr.id)::bigint as request_count,
|
||||
count(mr.id) filter (where mr.outcome = 'succeeded')::bigint as success_count,
|
||||
sum(mr.input_tokens)::bigint as input_tokens,
|
||||
sum(mr.output_tokens)::bigint as output_tokens,
|
||||
sum(mr.cached_tokens)::bigint as cached_tokens,
|
||||
sum(mr.cache_write_tokens)::bigint as cache_write_tokens,
|
||||
sum(mr.reasoning_tokens)::bigint as reasoning_tokens,
|
||||
sum(mr.image_input_tokens)::bigint as image_input_tokens,
|
||||
sum(mr.image_output_tokens)::bigint as image_output_tokens,
|
||||
count(mr.id) filter (where mr.image_generation_succeeded is true)::bigint
|
||||
as image_request_count,
|
||||
count(mr.id) filter (where mr.image_generation_succeeded is false)::bigint
|
||||
as image_request_failed_count,
|
||||
coalesce(sum(coalesce(
|
||||
mr.total_tokens,
|
||||
coalesce(mr.input_tokens, 0) + coalesce(mr.output_tokens, 0)
|
||||
)), 0)::bigint as total_tokens,
|
||||
count(mr.id) filter (where mr.cost_source = 'provider_reported')::bigint
|
||||
as provider_reported_count,
|
||||
count(mr.id) filter (where mr.cost_source = 'calculated')::bigint
|
||||
as calculated_count,
|
||||
count(mr.id) filter (where mr.cost_source = 'unavailable')::bigint
|
||||
as unavailable_count,
|
||||
max(mr.started_at) as last_used_at
|
||||
from requested_windows requested
|
||||
left join model_requests mr
|
||||
on mr.provider_account_ref = requested.account_id
|
||||
and mr.started_at >= requested.window_start
|
||||
and mr.started_at < requested.window_end
|
||||
and mr.outcome = 'succeeded'
|
||||
and mr.downstream_committed_at is not null
|
||||
and mr.client_status_code between 200 and 399
|
||||
group by requested.account_id, requested.window_key
|
||||
order by requested.account_id, requested.window_key";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderAccountAdminScope {
|
||||
pub provider_kind: String,
|
||||
@@ -898,6 +940,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
/// 三个 PostgreSQL adapter 都保持私有,调用方只能取得 [`AccountStore`] 暴露的领域能力。
|
||||
#[derive(Clone)]
|
||||
pub struct PgAdminAccountStore {
|
||||
pool: PgPool,
|
||||
accounts: PgProviderAccountRepository,
|
||||
observability: PgObservabilityRepository,
|
||||
control_plane: PgControlPlaneRepository,
|
||||
@@ -907,6 +950,7 @@ impl PgAdminAccountStore {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self {
|
||||
pool: pool.clone(),
|
||||
accounts: PgProviderAccountRepository::new(pool.clone()),
|
||||
observability: PgObservabilityRepository::new(pool.clone()),
|
||||
control_plane: PgControlPlaneRepository::new(pool),
|
||||
@@ -942,6 +986,61 @@ impl PgAdminAccountStore {
|
||||
Ok(observations)
|
||||
}
|
||||
|
||||
async fn usage_by_windows(
|
||||
&self,
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
if windows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let account_ids = windows
|
||||
.iter()
|
||||
.map(|window| window.account_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
validate_admin_account_ids(&account_ids)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
for window in windows {
|
||||
require_nonempty(ENTITY, "quota window key", &window.key)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
ObservabilityRange::new(window.range.start, window.range.end)
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
}
|
||||
let keys = windows
|
||||
.iter()
|
||||
.map(|window| window.key.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let starts = windows
|
||||
.iter()
|
||||
.map(|window| window.range.start)
|
||||
.collect::<Vec<_>>();
|
||||
let ends = windows
|
||||
.iter()
|
||||
.map(|window| window.range.end)
|
||||
.collect::<Vec<_>>();
|
||||
let account_ids = windows
|
||||
.iter()
|
||||
.map(|window| window.account_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
sqlx::query(ACCOUNT_USAGE_BY_WINDOWS_SQL)
|
||||
.bind(account_ids)
|
||||
.bind(keys)
|
||||
.bind(starts)
|
||||
.bind(ends)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
admin_store_error(
|
||||
ENTITY,
|
||||
postgres_unavailable("load provider account quota window usage"),
|
||||
)
|
||||
})?
|
||||
.iter()
|
||||
.map(admin_account_usage_window)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn required_scope(
|
||||
&self,
|
||||
account_id: &str,
|
||||
@@ -1164,6 +1263,13 @@ impl AccountStore for PgAdminAccountStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
self.usage_by_windows(windows).await
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -1622,11 +1728,25 @@ fn admin_account_summary(
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX);
|
||||
let attention = u64::try_from(
|
||||
accounts
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
matches!(
|
||||
admin_account_status(account, now),
|
||||
AdminAccountStatus::Expired
|
||||
| AdminAccountStatus::Disabled
|
||||
| AdminAccountStatus::Banned
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX);
|
||||
AccountSummary {
|
||||
total,
|
||||
active,
|
||||
quota_exhausted,
|
||||
attention: total.saturating_sub(active),
|
||||
attention,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1804,6 +1924,74 @@ fn admin_account_usage(usage: ProviderAccountUsageObservation) -> AdminStoreResu
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_account_usage_window(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
) -> AdminStoreResult<AccountUsageWindowResult> {
|
||||
let provider_reported_count = window_usage_count(row, "provider_reported_count")?;
|
||||
let calculated_count = window_usage_count(row, "calculated_count")?;
|
||||
let unavailable_count = window_usage_count(row, "unavailable_count")?;
|
||||
Ok(AccountUsageWindowResult {
|
||||
account_id: window_usage_value(row, "account_id")?,
|
||||
key: window_usage_value(row, "window_key")?,
|
||||
usage: AccountUsage {
|
||||
account_id: window_usage_value(row, "account_id")?,
|
||||
request_count: window_usage_count(row, "request_count")?,
|
||||
success_count: window_usage_count(row, "success_count")?,
|
||||
input_tokens: optional_window_usage_count(row, "input_tokens")?,
|
||||
output_tokens: optional_window_usage_count(row, "output_tokens")?,
|
||||
cached_tokens: optional_window_usage_count(row, "cached_tokens")?,
|
||||
cache_write_tokens: optional_window_usage_count(row, "cache_write_tokens")?,
|
||||
reasoning_tokens: optional_window_usage_count(row, "reasoning_tokens")?,
|
||||
image_input_tokens: optional_window_usage_count(row, "image_input_tokens")?,
|
||||
image_output_tokens: optional_window_usage_count(row, "image_output_tokens")?,
|
||||
image_request_count: window_usage_count(row, "image_request_count")?,
|
||||
image_request_failed_count: window_usage_count(row, "image_request_failed_count")?,
|
||||
total_tokens: optional_window_usage_count(row, "total_tokens")?,
|
||||
cost_coverage: AdminCostCoverage {
|
||||
provider_reported_count,
|
||||
calculated_count,
|
||||
partial_count: 0,
|
||||
unavailable_count,
|
||||
not_billable_count: 0,
|
||||
},
|
||||
costs: Vec::new(),
|
||||
last_used_at: window_usage_value(row, "last_used_at")?,
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn window_usage_count(row: &sqlx::postgres::PgRow, column: &'static str) -> AdminStoreResult<u64> {
|
||||
let value = window_usage_value::<i64>(row, column)?;
|
||||
u64::try_from(value).map_err(|_| invalid_window_usage(column))
|
||||
}
|
||||
|
||||
fn optional_window_usage_count(
|
||||
row: &sqlx::postgres::PgRow,
|
||||
column: &'static str,
|
||||
) -> AdminStoreResult<Option<u64>> {
|
||||
window_usage_value::<Option<i64>>(row, column)?
|
||||
.map(|value| u64::try_from(value).map_err(|_| invalid_window_usage(column)))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn window_usage_value<T>(row: &sqlx::postgres::PgRow, column: &'static str) -> AdminStoreResult<T>
|
||||
where
|
||||
for<'row> T: sqlx::Decode<'row, Postgres> + sqlx::Type<Postgres>,
|
||||
{
|
||||
row.try_get(column)
|
||||
.map_err(|_| invalid_window_usage(column))
|
||||
}
|
||||
|
||||
fn invalid_window_usage(column: &'static str) -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Invalid,
|
||||
ENTITY,
|
||||
format!("invalid quota window usage column: {column}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_account_model_usage(
|
||||
usage: ProviderAccountModelUsageObservation,
|
||||
) -> AdminStoreResult<AccountModelUsage> {
|
||||
|
||||
@@ -264,6 +264,7 @@ fn successful_core_finalization(id: &str) -> CoreModelRequestFinalization {
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
http_version: Some("HTTP/2".to_owned()),
|
||||
websocket_pool: None,
|
||||
provider_metadata_json: None,
|
||||
error: None,
|
||||
provider_error_code: None,
|
||||
retry_after_ms: None,
|
||||
@@ -275,6 +276,54 @@ fn successful_core_finalization(id: &str) -> CoreModelRequestFinalization {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_adapter_should_persist_only_object_provider_observation() {
|
||||
let Some(database) = TestDatabase::create("execution_provider_observation").await else {
|
||||
return;
|
||||
};
|
||||
seed_running_request(&database.pool, "req_provider_observation")
|
||||
.await
|
||||
.expect("seed model request");
|
||||
let store = PgExecutionStore::new(database.pool.clone());
|
||||
let mut finalization = successful_core_finalization("req_provider_observation");
|
||||
finalization.provider_metadata_json = Some("{\"effectiveModel\":\"gpt-test\"}".to_owned());
|
||||
|
||||
ExecutionStore::finalize_model_request(&store, finalization)
|
||||
.await
|
||||
.expect("persist provider observation");
|
||||
let persisted: serde_json::Value = sqlx::query_scalar(
|
||||
"select provider_observation_json from model_requests where id = 'req_provider_observation'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load provider observation");
|
||||
assert_eq!(persisted["effectiveModel"], "gpt-test");
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_adapter_should_reject_non_object_provider_observation() {
|
||||
let Some(database) = TestDatabase::create("execution_invalid_provider_observation").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
seed_running_request(&database.pool, "req_invalid_provider_observation")
|
||||
.await
|
||||
.expect("seed model request");
|
||||
let store = PgExecutionStore::new(database.pool.clone());
|
||||
let mut finalization = successful_core_finalization("req_invalid_provider_observation");
|
||||
finalization.provider_metadata_json = Some("[]".to_owned());
|
||||
|
||||
assert!(
|
||||
ExecutionStore::finalize_model_request(&store, finalization)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
async fn seed_running_request(pool: &sqlx::PgPool, id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"insert into model_requests (
|
||||
|
||||
@@ -132,7 +132,7 @@ async fn connect_and_migrate_should_apply_0001_once_and_reopen_cleanly() {
|
||||
.expect("drop migration test database");
|
||||
admin.close().await;
|
||||
|
||||
assert_eq!((first_table_count, migration_count), (9, 1));
|
||||
assert_eq!((first_table_count, migration_count), (8, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,6 +62,48 @@ fn postgres_admin_observability_adapter_implements_terminal_port() {
|
||||
assert_port::<PgAdminObservabilityStore>();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calculated_usage_billing_facts_keep_only_completed_calculated_costs() {
|
||||
let Some(database) = TestDatabase::create("calculated_usage_billing_facts").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_observability_facts(&database.pool, now)
|
||||
.await
|
||||
.expect("seed observability facts");
|
||||
seed_calculated_billing_facts(&database.pool, now)
|
||||
.await
|
||||
.expect("seed calculated billing facts");
|
||||
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
|
||||
.expect("observability range");
|
||||
let repository = PgObservabilityRepository::new(database.pool.clone());
|
||||
|
||||
let facts = repository
|
||||
.usage_calculated_billing_facts(range, UsageRecordFilter::default())
|
||||
.await
|
||||
.expect("calculated usage billing facts");
|
||||
assert_eq!(facts.len(), 1);
|
||||
assert_eq!(facts[0].provider_kind, "openai");
|
||||
assert_eq!(facts[0].upstream_model_id, "gpt-5.5");
|
||||
assert_eq!(facts[0].input_tokens, Some(800));
|
||||
assert_eq!(facts[0].output_tokens, Some(200));
|
||||
assert_eq!(facts[0].total.amount.as_str(), "1.25");
|
||||
|
||||
let store = PgAdminObservabilityStore::new(database.pool.clone(), None);
|
||||
let facts = store
|
||||
.usage_calculated_billing_facts(
|
||||
admin_observability::TimeRange::new(range.start, range.end)
|
||||
.expect("admin observability range"),
|
||||
admin_observability::UsageFilter::default(),
|
||||
)
|
||||
.await
|
||||
.expect("admin calculated usage billing facts");
|
||||
assert_eq!(facts.len(), 1);
|
||||
assert_eq!(facts[0].total.amount.as_str(), "1.25");
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_details() {
|
||||
let Some(database) = TestDatabase::create("admin_observability").await else {
|
||||
@@ -81,17 +123,17 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
.await
|
||||
.expect("admin dashboard summary");
|
||||
assert_eq!(dashboard.range, range);
|
||||
assert_eq!(dashboard.requests.request_count, 2);
|
||||
assert_eq!(dashboard.requests.request_count, 1);
|
||||
assert_eq!(dashboard.requests.first_token_latency_sum_ms, 120);
|
||||
assert_eq!(dashboard.requests.latency_sum_ms, 1_600);
|
||||
assert_eq!(dashboard.requests.min_latency_ms, Some(700));
|
||||
assert_eq!(dashboard.requests.latency_sum_ms, 900);
|
||||
assert_eq!(dashboard.requests.min_latency_ms, Some(900));
|
||||
assert_eq!(dashboard.requests.max_latency_ms, Some(900));
|
||||
assert_eq!(dashboard.attempts.attempt_count, 3);
|
||||
assert_eq!(dashboard.attempts.attempt_count, 4);
|
||||
assert_eq!(dashboard.attempts.cost_coverage.provider_reported_count, 1);
|
||||
assert_eq!(dashboard.attempts.cost_coverage.unavailable_count, 1);
|
||||
assert_eq!(dashboard.attempts.cost_coverage.unavailable_count, 0);
|
||||
assert_eq!(dashboard.attempts.costs[0].amount.as_str(), "1.25");
|
||||
assert_eq!(dashboard.provider_accounts.total, 1);
|
||||
assert_eq!(dashboard.account_usage[0].request_count, 2);
|
||||
assert_eq!(dashboard.account_usage[0].request_count, 1);
|
||||
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 24);
|
||||
assert_eq!(
|
||||
dashboard.account_usage[0]
|
||||
@@ -99,7 +141,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
.iter()
|
||||
.map(|bucket| bucket.request_count)
|
||||
.sum::<u64>(),
|
||||
2,
|
||||
1,
|
||||
);
|
||||
assert_eq!(dashboard.recent_requests.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -124,7 +166,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
.iter()
|
||||
.map(|point| point.metrics.request_count)
|
||||
.sum::<u64>(),
|
||||
2,
|
||||
1,
|
||||
);
|
||||
|
||||
let trend = store
|
||||
@@ -165,20 +207,9 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
})
|
||||
.await
|
||||
.expect("first usage page");
|
||||
assert_eq!(first_page.total, 2);
|
||||
assert_eq!(first_page.total, 1);
|
||||
assert_eq!(first_page.items.len(), 1);
|
||||
let second_page = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
filter: admin_observability::UsageFilter::default(),
|
||||
cursor: first_page.next_cursor,
|
||||
page: admin_page(1),
|
||||
page_size: PageSize::new(1).expect("page size"),
|
||||
})
|
||||
.await
|
||||
.expect("second usage page");
|
||||
assert_eq!(second_page.items.len(), 1);
|
||||
assert_ne!(first_page.items[0].id, second_page.items[0].id);
|
||||
assert!(first_page.next_cursor.is_none());
|
||||
|
||||
let filtered = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
@@ -230,21 +261,29 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
assert!(other_outcome.items.is_empty());
|
||||
|
||||
let detail = store
|
||||
.usage_record_detail("req_observe_failed")
|
||||
.usage_record_detail("req_observe_success")
|
||||
.await
|
||||
.expect("admin usage detail");
|
||||
assert_eq!(
|
||||
detail.request.outcome,
|
||||
admin_observability::RequestOutcome::Failed
|
||||
admin_observability::RequestOutcome::Succeeded
|
||||
);
|
||||
assert_eq!(detail.attempts.len(), 2);
|
||||
assert_eq!(detail.attempts.len(), 1);
|
||||
assert_eq!(
|
||||
detail.attempts[0].outcome,
|
||||
admin_observability::RequestOutcome::Failed
|
||||
admin_observability::RequestOutcome::Succeeded
|
||||
);
|
||||
assert_eq!(
|
||||
detail.attempts[1].outcome,
|
||||
admin_observability::RequestOutcome::Failed
|
||||
assert!(
|
||||
store
|
||||
.usage_record_detail("req_observe_failed")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.usage_record_detail("req_observe_uncommitted")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let overview = store
|
||||
@@ -352,10 +391,10 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.dashboard_summary(range)
|
||||
.await
|
||||
.expect("dashboard summary");
|
||||
assert_eq!(dashboard.requests.request_count, 2);
|
||||
assert_eq!(dashboard.requests.cache_eligible_request_count, 2);
|
||||
assert_eq!(dashboard.requests.request_count, 1);
|
||||
assert_eq!(dashboard.requests.cache_eligible_request_count, 1);
|
||||
assert_eq!(dashboard.requests.cache_hit_request_count, 1);
|
||||
assert_eq!(dashboard.requests.cache_hit_request_rate(), Some(0.5));
|
||||
assert_eq!(dashboard.requests.cache_hit_request_rate(), Some(1.0));
|
||||
assert_eq!(
|
||||
dashboard
|
||||
.requests
|
||||
@@ -363,7 +402,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.p50_ms
|
||||
.expect("latency p50")
|
||||
.as_f64(),
|
||||
800.0
|
||||
900.0
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard
|
||||
@@ -372,7 +411,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.p95_ms
|
||||
.expect("latency p95")
|
||||
.as_f64(),
|
||||
890.0
|
||||
900.0
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard
|
||||
@@ -381,7 +420,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.p99_ms
|
||||
.expect("latency p99")
|
||||
.as_f64(),
|
||||
898.0
|
||||
900.0
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard
|
||||
@@ -393,7 +432,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
120.0
|
||||
);
|
||||
assert_eq!(dashboard.provider_accounts.total, 1);
|
||||
assert_eq!(dashboard.account_usage[0].request_count, 2);
|
||||
assert_eq!(dashboard.account_usage[0].request_count, 1);
|
||||
assert_eq!(dashboard.account_usage[0].request_buckets.len(), 24);
|
||||
assert_eq!(
|
||||
dashboard.account_usage[0]
|
||||
@@ -401,7 +440,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.iter()
|
||||
.map(|bucket| bucket.request_count)
|
||||
.sum::<u64>(),
|
||||
2,
|
||||
1,
|
||||
);
|
||||
assert_eq!(dashboard.recent_requests.len(), 1);
|
||||
assert_eq!(dashboard.recent_requests[0].id, "req_observe_success");
|
||||
@@ -411,7 +450,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.iter()
|
||||
.map(|point| point.metrics.request_count)
|
||||
.sum::<u64>(),
|
||||
2
|
||||
1
|
||||
);
|
||||
|
||||
let account_usage = repository
|
||||
@@ -423,11 +462,11 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
)
|
||||
.await
|
||||
.expect("provider account usage");
|
||||
assert_eq!(account_usage[0].request_count, 2);
|
||||
assert_eq!(account_usage[0].request_count, 1);
|
||||
assert_eq!(account_usage[0].authentication_kind, "oauth");
|
||||
assert_eq!(account_usage[0].models[0].request_count, 2);
|
||||
assert_eq!(account_usage[0].models[0].request_count, 1);
|
||||
assert_eq!(account_usage[0].cost_coverage.provider_reported_count, 1);
|
||||
assert_eq!(account_usage[0].cost_coverage.unavailable_count, 1);
|
||||
assert_eq!(account_usage[0].cost_coverage.unavailable_count, 0);
|
||||
assert_eq!(account_usage[0].costs[0].amount.as_str(), "1.25");
|
||||
assert_eq!(
|
||||
account_usage[0]
|
||||
@@ -435,7 +474,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.iter()
|
||||
.map(|bucket| bucket.request_count)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2, 0],
|
||||
vec![1, 0],
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
@@ -446,7 +485,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
account_usage[0].models[0].image_request_count,
|
||||
account_usage[0].models[0].image_request_failed_count,
|
||||
),
|
||||
(Some(31), Some(9), 1, 1, 1, 1)
|
||||
(Some(31), Some(9), 1, 0, 1, 0)
|
||||
);
|
||||
|
||||
let usage_page = repository
|
||||
@@ -462,7 +501,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
})
|
||||
.await
|
||||
.expect("usage records");
|
||||
assert_eq!(usage_page.total, 2);
|
||||
assert_eq!(usage_page.total, 1);
|
||||
let successful_image = usage_page
|
||||
.items
|
||||
.iter()
|
||||
@@ -489,20 +528,20 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
)
|
||||
);
|
||||
|
||||
let detail = repository
|
||||
.usage_record_detail("req_observe_failed")
|
||||
.await
|
||||
.expect("usage detail");
|
||||
assert_eq!(detail.attempts.len(), 2);
|
||||
assert_eq!(detail.attempts[0].source, "ops_event");
|
||||
assert_eq!(detail.attempts[1].source, "model_request");
|
||||
assert!(
|
||||
repository
|
||||
.usage_record_detail("req_observe_failed")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let overview = repository
|
||||
.usage_summary(range, UsageRecordFilter::default())
|
||||
.await
|
||||
.expect("usage summary");
|
||||
assert_eq!(overview.attempts.attempt_count, 3);
|
||||
assert_eq!(overview.attempts.attempt_count, 4);
|
||||
assert_eq!(overview.attempts.failure_count, 2);
|
||||
assert_eq!(overview.requests.request_count, 1);
|
||||
|
||||
let succeeded = repository
|
||||
.usage_summary(
|
||||
@@ -536,7 +575,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
.await
|
||||
.expect("usage diagnostics");
|
||||
assert_eq!(diagnostics[0].name, "acct_observe");
|
||||
assert_eq!(diagnostics[0].request_count, 2);
|
||||
assert_eq!(diagnostics[0].request_count, 1);
|
||||
assert_eq!(diagnostics[0].costs[0].amount.as_str(), "1.25");
|
||||
|
||||
let errors = repository
|
||||
@@ -574,6 +613,27 @@ async fn seed_observability_facts(
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"insert into model_requests (
|
||||
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
|
||||
client_transport, requested_model_id,
|
||||
provider_kind, provider_account_id,
|
||||
provider_account_ref, upstream_model_id, upstream_transport,
|
||||
attempt_count, upstream_send_state, outcome, client_status_code,
|
||||
input_tokens, output_tokens, total_tokens, cost_source, latency_ms,
|
||||
started_at, deadline_at, completed_at
|
||||
) values (
|
||||
'req_observe_uncommitted', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
|
||||
'http_sse', 'public-model', 'openai', 'acct_observe',
|
||||
'acct_observe', 'upstream-model', 'http_sse',
|
||||
1, 'sent', 'succeeded', 200,
|
||||
900, 900, 1800, 'unavailable', 650,
|
||||
$1 - interval '15 minutes', $1 + interval '10 minutes', $1 - interval '14 minutes'
|
||||
)",
|
||||
)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"insert into model_requests (
|
||||
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
|
||||
@@ -643,3 +703,48 @@ async fn seed_observability_facts(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_calculated_billing_facts(
|
||||
pool: &PgPool,
|
||||
now: chrono::DateTime<Utc>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"insert into model_requests (
|
||||
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
|
||||
client_transport, requested_model_id, provider_kind, provider_account_id,
|
||||
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
|
||||
upstream_send_state, downstream_committed_at, outcome, client_status_code,
|
||||
input_tokens, output_tokens, cached_tokens, cache_write_tokens, total_tokens,
|
||||
cost_source, cost_amount, cost_currency, started_at, deadline_at, completed_at
|
||||
) values (
|
||||
'req_observe_calculated', 'key_observe', 1, 'openai', 'responses', '/v1/responses',
|
||||
'http_sse', 'public-model', 'openai', 'acct_observe', 'acct_observe', 'gpt-5.5',
|
||||
'http_sse', 1, 'sent', $1 - interval '29 minutes', 'succeeded', 200,
|
||||
800, 200, 0, 0, 1000, 'calculated', 1.25, 'USD',
|
||||
$1 - interval '30 minutes', $1 + interval '10 minutes', $1 - interval '29 minutes'
|
||||
)",
|
||||
)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"insert into model_requests (
|
||||
id, client_api_key_ref, config_revision, protocol, operation, endpoint,
|
||||
client_transport, requested_model_id, provider_kind, provider_account_id,
|
||||
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
|
||||
upstream_send_state, outcome, client_status_code, input_tokens, output_tokens,
|
||||
total_tokens, cost_source, cost_amount, cost_currency, started_at, deadline_at,
|
||||
completed_at
|
||||
) values (
|
||||
'req_observe_calculated_uncommitted', 'key_observe', 1, 'openai', 'responses',
|
||||
'/v1/responses', 'http_sse', 'public-model', 'openai', 'acct_observe',
|
||||
'acct_observe', 'gpt-5.5', 'http_sse', 1, 'sent', 'succeeded', 200, 800, 200,
|
||||
1000, 'calculated', 1.25, 'USD',
|
||||
$1 - interval '40 minutes', $1 + interval '10 minutes', $1 - interval '39 minutes'
|
||||
)",
|
||||
)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ use gateway_admin::{
|
||||
model::{
|
||||
MutationActor, MutationContext, PageSize, Revision as AdminRevision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountSort, AccountSortField, AccountStatus, DeleteAccounts,
|
||||
SetAccountEnabled as AdminSetAccountEnabled, SortDirection,
|
||||
AccountListQuery, AccountSort, AccountSortField, AccountStatus,
|
||||
AccountUsageWindowQuery, DeleteAccounts, SetAccountEnabled as AdminSetAccountEnabled,
|
||||
SortDirection,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
@@ -128,7 +129,10 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
let mut attention = account("acct_attention", "user-attention");
|
||||
attention.email = Some("attention@example.invalid".to_owned());
|
||||
attention.availability = ProviderAccountAvailability::Invalid;
|
||||
for account in [alpha, beta, charlie, attention] {
|
||||
let mut quota_exhausted = account("acct_quota_exhausted", "user-quota-exhausted");
|
||||
quota_exhausted.email = Some("quota-exhausted@example.invalid".to_owned());
|
||||
quota_exhausted.availability = ProviderAccountAvailability::QuotaExhausted;
|
||||
for account in [alpha, beta, charlie, attention, quota_exhausted] {
|
||||
repository
|
||||
.insert_provider_account(account)
|
||||
.await
|
||||
@@ -208,11 +212,11 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
|
||||
.await
|
||||
.expect("sort accounts by retained usage");
|
||||
assert_eq!(usage_page.config_revision.get(), 1);
|
||||
assert_eq!(usage_page.total, 4);
|
||||
assert_eq!(usage_page.summary.total, 4);
|
||||
assert_eq!(usage_page.total, 5);
|
||||
assert_eq!(usage_page.summary.total, 5);
|
||||
assert_eq!(usage_page.summary.active, 2);
|
||||
assert_eq!(usage_page.summary.quota_exhausted, 0);
|
||||
assert_eq!(usage_page.summary.attention, 2);
|
||||
assert_eq!(usage_page.summary.quota_exhausted, 1);
|
||||
assert_eq!(usage_page.summary.attention, 1);
|
||||
assert_eq!(
|
||||
usage_page
|
||||
.items
|
||||
@@ -427,6 +431,96 @@ async fn terminal_admin_usage_chunks_large_selections_and_preserves_exact_costs(
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_admin_quota_window_usage_prefers_provider_total_and_falls_back_to_components() {
|
||||
let Some(database) = TestDatabase::create("provider_account_quota_window_usage").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
repository
|
||||
.insert_provider_account(account("acct_quota_window", "user-quota-window"))
|
||||
.await
|
||||
.expect("insert quota window account");
|
||||
let now = Utc::now();
|
||||
seed_model_request(
|
||||
&database.pool,
|
||||
ModelRequestSeed {
|
||||
request_id: "req_quota_window_recent",
|
||||
account_id: "acct_quota_window",
|
||||
provider_kind: "openai",
|
||||
model: "gpt-window",
|
||||
total_tokens: 10,
|
||||
cost_amount: "0",
|
||||
started_at: now - TimeDelta::minutes(30),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed recent quota window request");
|
||||
seed_model_request(
|
||||
&database.pool,
|
||||
ModelRequestSeed {
|
||||
request_id: "req_quota_window_older",
|
||||
account_id: "acct_quota_window",
|
||||
provider_kind: "openai",
|
||||
model: "gpt-window",
|
||||
total_tokens: 20,
|
||||
cost_amount: "0",
|
||||
started_at: now - TimeDelta::hours(6),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed older quota window request");
|
||||
sqlx::query(
|
||||
"update model_requests
|
||||
set input_tokens = case id
|
||||
when 'req_quota_window_recent' then 7
|
||||
when 'req_quota_window_older' then 11
|
||||
end,
|
||||
output_tokens = case id
|
||||
when 'req_quota_window_recent' then 3
|
||||
when 'req_quota_window_older' then 9
|
||||
end,
|
||||
total_tokens = case id
|
||||
when 'req_quota_window_recent' then 700
|
||||
when 'req_quota_window_older' then null
|
||||
end
|
||||
where id in ('req_quota_window_recent', 'req_quota_window_older')",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("separate provider total from fallback components");
|
||||
|
||||
let mut usage = PgAdminAccountStore::new(database.pool.clone())
|
||||
.load_account_usage_by_windows(&[
|
||||
AccountUsageWindowQuery {
|
||||
account_id: "acct_quota_window".to_owned(),
|
||||
key: "short".to_owned(),
|
||||
range: TimeRange {
|
||||
start: now - TimeDelta::hours(1),
|
||||
end: now + TimeDelta::minutes(1),
|
||||
},
|
||||
},
|
||||
AccountUsageWindowQuery {
|
||||
account_id: "acct_quota_window".to_owned(),
|
||||
key: "long".to_owned(),
|
||||
range: TimeRange {
|
||||
start: now - TimeDelta::hours(7),
|
||||
end: now + TimeDelta::minutes(1),
|
||||
},
|
||||
},
|
||||
])
|
||||
.await
|
||||
.expect("load quota window usage");
|
||||
usage.sort_by(|left, right| left.key.cmp(&right.key));
|
||||
|
||||
assert_eq!(usage[0].key, "long");
|
||||
assert_eq!(usage[0].usage.total_tokens, Some(720));
|
||||
assert_eq!(usage[1].key, "short");
|
||||
assert_eq!(usage[1].usage.total_tokens, Some(700));
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
|
||||
let Some(database) = TestDatabase::create("provider_account_terminal_mutation").await else {
|
||||
@@ -1014,14 +1108,15 @@ async fn seed_model_request(
|
||||
client_transport, requested_model_id,
|
||||
provider_kind, provider_account_id,
|
||||
provider_account_ref, upstream_model_id, upstream_transport, attempt_count,
|
||||
upstream_send_state, outcome, client_status_code, upstream_status_code,
|
||||
upstream_send_state, downstream_committed_at, outcome, client_status_code,
|
||||
upstream_status_code,
|
||||
input_tokens, output_tokens, cached_tokens, cache_write_tokens, reasoning_tokens,
|
||||
total_tokens, cost_source, cost_amount, cost_currency,
|
||||
started_at, deadline_at, completed_at
|
||||
) values (
|
||||
$1, 'key-provider-account-test', 1, 'openai', 'responses', '/v1/responses',
|
||||
'http_sse', $4, $3, $2, $2, $4, 'http_sse', 1,
|
||||
'sent', 'succeeded', 200, 200, $5, 0, 0, 0, 0,
|
||||
'sent', $7 + interval '1 second', 'succeeded', 200, 200, $5, 0, 0, 0, 0,
|
||||
$5, 'provider_reported', $6::numeric, 'USD', $7,
|
||||
$7 + interval '5 minutes', $7 + interval '1 second'
|
||||
)",
|
||||
|
||||
@@ -159,7 +159,7 @@ impl Default for CodexWebSocketPoolSettings {
|
||||
max_per_account: 8,
|
||||
max_total: 64,
|
||||
max_connecting: 8,
|
||||
initial_event_timeout_ms: 800,
|
||||
initial_event_timeout_ms: 20_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::{StreamExt, future::BoxFuture};
|
||||
@@ -22,8 +22,8 @@ use gateway_core::error::{
|
||||
SafeUpstreamValue,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
|
||||
ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseMetadata,
|
||||
ProviderResponseObservation, ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, OperationKind, ProviderSessionState};
|
||||
use gateway_core::provider_ports::ProviderSessionAffinityKey;
|
||||
@@ -36,7 +36,7 @@ use gateway_core::task::{
|
||||
use reqwest::Client;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value, json};
|
||||
use url::Url;
|
||||
|
||||
use crate::credential::{
|
||||
@@ -57,7 +57,9 @@ use crate::transport::profile::{
|
||||
APPCAST_POLL_INTERVAL, CodexCliReleaseService, CodexDesktopReleaseService,
|
||||
CodexWireProfileState,
|
||||
};
|
||||
use crate::transport::protocol::responses::{CodexResponsesRequest, PreviousResponseScope};
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
|
||||
};
|
||||
use crate::transport::request::{
|
||||
CodexRequestEncodeError, encode_generate_request, sanitize_cross_account_item,
|
||||
scope_request_to_account,
|
||||
@@ -66,8 +68,8 @@ use crate::transport::session::CodexSessionIdentity;
|
||||
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
|
||||
use crate::transport::{
|
||||
CODEX_RESPONSES_PATH, CodexBackendClient, CodexBackendStreamingResponse, CodexBackendTransport,
|
||||
CodexClientError, CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics,
|
||||
CodexUpstreamDiagnostics, CodexWebSocketPool, endpoint_url,
|
||||
CodexClientError, CodexRateLimitHeaderUpdates, CodexRequestContext, CodexResponseMetadata,
|
||||
CodexTransportMetrics, CodexUpstreamDiagnostics, CodexWebSocketPool, endpoint_url,
|
||||
};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
@@ -779,31 +781,13 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
});
|
||||
capture.turn_state = response.turn_state.clone().or(capture.turn_state.clone());
|
||||
}
|
||||
let observation = match codex_response_observation(
|
||||
response.transport,
|
||||
&response.diagnostics,
|
||||
&response.response_metadata,
|
||||
&response.transport_metrics,
|
||||
response.websocket_pool_decision,
|
||||
) {
|
||||
Ok(observation) => observation,
|
||||
Err(error) => {
|
||||
let failure = MappedProviderFailure::plain(error);
|
||||
apply_failure(
|
||||
&client,
|
||||
&selector,
|
||||
"a,
|
||||
&active_account,
|
||||
&response_origin,
|
||||
cyber_policy_scope.as_ref(),
|
||||
&failure,
|
||||
)
|
||||
.await;
|
||||
Err(failure.error)?;
|
||||
return;
|
||||
}
|
||||
};
|
||||
yield ProviderEvent::observation(observation);
|
||||
let mut observation_state = OpenAiResponseObservationState::from_backend_response(
|
||||
&response,
|
||||
&request,
|
||||
active_account.id().as_str(),
|
||||
context.attempt_index().get(),
|
||||
);
|
||||
yield ProviderEvent::observation(observation_state.observation()?);
|
||||
synchronize_passive_quota("a, &active_account, &response.rate_limit_headers).await;
|
||||
if let Some(etag) = response.response_metadata.models_etag.as_deref()
|
||||
&& let Err(error) = catalog.observe_response_etag(etag)
|
||||
@@ -861,6 +845,13 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let next = match next {
|
||||
Ok(next) => next,
|
||||
Err(failure) => {
|
||||
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
|
||||
if !updates.is_empty() {
|
||||
synchronize_passive_quota("a, &active_account, &updates).await;
|
||||
if observation_state.merge_rate_limit_headers(&updates) {
|
||||
yield ProviderEvent::observation(observation_state.observation()?);
|
||||
}
|
||||
}
|
||||
apply_failure(
|
||||
&client,
|
||||
&selector,
|
||||
@@ -876,16 +867,21 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
}
|
||||
};
|
||||
let Some(chunk) = next else { break; };
|
||||
if let Some(updates) = rate_limit_updates.as_ref() {
|
||||
let updates = std::mem::take(&mut *updates.lock().await);
|
||||
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
|
||||
let rate_limits_changed = if updates.is_empty() {
|
||||
false
|
||||
} else {
|
||||
synchronize_passive_quota("a, &active_account, &updates).await;
|
||||
}
|
||||
observation_state.merge_rate_limit_headers(&updates)
|
||||
};
|
||||
if let (Some(capture), Some(updates)) =
|
||||
(session_capture.as_mut(), turn_state_updates.as_ref())
|
||||
&& let Some(turn_state) = updates.lock().await.clone()
|
||||
{
|
||||
capture.turn_state = Some(turn_state);
|
||||
}
|
||||
let first_event_changed =
|
||||
observation_state.observe_stream_chunk(&chunk, context.timing_started_at());
|
||||
let (mut events, terminal_failure) = match decoder.push(&chunk) {
|
||||
CodexCanonicalOutcome::Events(events) => (events, None),
|
||||
CodexCanonicalOutcome::Failed(failure) => {
|
||||
@@ -900,6 +896,18 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
(events, Some(failure))
|
||||
}
|
||||
};
|
||||
let timing_changed = first_event_changed
|
||||
|| observation_state
|
||||
.observe_timing_signals(decoder.take_timing_signals(), context.timing_started_at());
|
||||
let completed = events
|
||||
.iter()
|
||||
.flat_map(ProviderEvent::canonical_facts)
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)));
|
||||
let terminal_changed = completed
|
||||
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
|
||||
if rate_limits_changed || timing_changed || terminal_changed {
|
||||
yield ProviderEvent::observation(observation_state.observation()?);
|
||||
}
|
||||
if let Some(failure) = terminal_failure.as_ref() {
|
||||
apply_failure(
|
||||
&client,
|
||||
@@ -931,10 +939,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let completed = events
|
||||
.iter()
|
||||
.flat_map(ProviderEvent::canonical_facts)
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)));
|
||||
for event in events {
|
||||
yield event;
|
||||
}
|
||||
@@ -966,6 +970,15 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
(events, Some(failure))
|
||||
}
|
||||
};
|
||||
let timing_changed = observation_state
|
||||
.observe_timing_signals(decoder.take_timing_signals(), context.timing_started_at());
|
||||
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
|
||||
let rate_limits_changed = if updates.is_empty() {
|
||||
false
|
||||
} else {
|
||||
synchronize_passive_quota("a, &active_account, &updates).await;
|
||||
observation_state.merge_rate_limit_headers(&updates)
|
||||
};
|
||||
if let Some(failure) = terminal_failure.as_ref() {
|
||||
apply_failure(
|
||||
&client,
|
||||
@@ -1007,6 +1020,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
.iter()
|
||||
.flat_map(ProviderEvent::canonical_facts)
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)));
|
||||
let terminal_changed = completed
|
||||
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
|
||||
if timing_changed || rate_limits_changed || terminal_changed {
|
||||
yield ProviderEvent::observation(observation_state.observation()?);
|
||||
}
|
||||
for event in events {
|
||||
yield event;
|
||||
}
|
||||
@@ -1025,23 +1043,258 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
})
|
||||
}
|
||||
|
||||
/// OpenAI Responses 的观测状态完全归 Provider 所有。
|
||||
///
|
||||
/// 每次原始 SSE/WS 事件推进状态后重新生成不可变 observation,Core 只负责携带、
|
||||
/// 持久化和展示这份安全快照,不解释任何 OpenAI 协议字段。
|
||||
struct OpenAiResponseObservationState {
|
||||
transport: CodexBackendTransport,
|
||||
diagnostics: CodexUpstreamDiagnostics,
|
||||
response_metadata: CodexResponseMetadata,
|
||||
metrics: CodexTransportMetrics,
|
||||
websocket_pool_decision: Option<crate::transport::WebSocketPoolDecision>,
|
||||
request_summary: Value,
|
||||
requested_model: String,
|
||||
stream: bool,
|
||||
compact: bool,
|
||||
service_tier: Option<String>,
|
||||
rate_limit_headers: Vec<(String, String)>,
|
||||
account_id: String,
|
||||
attempt_index: u32,
|
||||
timings: ProviderResponseTimings,
|
||||
terminal: Option<OpenAiResponseTerminal>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum OpenAiResponseTerminal {
|
||||
Completed,
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
impl OpenAiResponseObservationState {
|
||||
fn from_backend_response(
|
||||
response: &CodexBackendStreamingResponse,
|
||||
request: &CodexResponsesRequest,
|
||||
account_id: &str,
|
||||
attempt_index: u32,
|
||||
) -> Self {
|
||||
let semantics = request.semantics();
|
||||
Self {
|
||||
transport: response.transport,
|
||||
diagnostics: response.diagnostics.clone(),
|
||||
response_metadata: response.response_metadata.clone(),
|
||||
metrics: response.transport_metrics.clone(),
|
||||
websocket_pool_decision: response.websocket_pool_decision,
|
||||
request_summary: openai_response_request_summary(request, response.transport),
|
||||
requested_model: request.model().to_owned(),
|
||||
stream: request.stream(),
|
||||
compact: semantics.compact,
|
||||
service_tier: request.service_tier().map(str::to_owned),
|
||||
rate_limit_headers: safe_observation_headers(&response.rate_limit_headers),
|
||||
account_id: account_id.to_owned(),
|
||||
attempt_index,
|
||||
timings: openai_response_timings(
|
||||
&response.transport_metrics,
|
||||
&response.response_metadata,
|
||||
),
|
||||
terminal: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(&self) -> Result<ProviderResponseObservation, ProviderError> {
|
||||
let mut observation = codex_response_observation(
|
||||
self.transport,
|
||||
&self.diagnostics,
|
||||
&self.response_metadata,
|
||||
&self.metrics,
|
||||
self.websocket_pool_decision,
|
||||
self.timings,
|
||||
)?;
|
||||
if let Some(metadata) = self.provider_metadata() {
|
||||
observation = observation.with_provider_metadata(metadata);
|
||||
}
|
||||
Ok(observation)
|
||||
}
|
||||
|
||||
fn observe_stream_chunk(&mut self, chunk: &[u8], started_at: Instant) -> bool {
|
||||
if chunk.is_empty() {
|
||||
return false;
|
||||
}
|
||||
insert_first_timing(&mut self.timings.first_event_ms, started_at)
|
||||
}
|
||||
|
||||
fn observe_timing_signals(
|
||||
&mut self,
|
||||
signals: ResponseEventSignals,
|
||||
started_at: Instant,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
if signals.semantic_output {
|
||||
changed |= insert_first_timing(&mut self.timings.first_token_ms, started_at);
|
||||
}
|
||||
if signals.reasoning_output {
|
||||
changed |= insert_first_timing(&mut self.timings.first_reasoning_ms, started_at);
|
||||
}
|
||||
if signals.text_output {
|
||||
changed |= insert_first_timing(&mut self.timings.first_text_ms, started_at);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn merge_rate_limit_headers(&mut self, updates: &[(String, String)]) -> bool {
|
||||
let mut changed = false;
|
||||
for (name, value) in safe_observation_headers(updates) {
|
||||
if let Some(existing) = self
|
||||
.rate_limit_headers
|
||||
.iter_mut()
|
||||
.find(|(existing_name, _)| existing_name == &name)
|
||||
{
|
||||
if existing.1 != value {
|
||||
existing.1 = value;
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
self.rate_limit_headers.push((name, value));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn mark_completed(&mut self, incomplete: bool) -> bool {
|
||||
let terminal = if incomplete {
|
||||
OpenAiResponseTerminal::Incomplete
|
||||
} else {
|
||||
OpenAiResponseTerminal::Completed
|
||||
};
|
||||
if self.terminal == Some(terminal) {
|
||||
return false;
|
||||
}
|
||||
self.terminal = Some(terminal);
|
||||
true
|
||||
}
|
||||
|
||||
fn provider_metadata(&self) -> Option<ProviderResponseMetadata> {
|
||||
let mut metadata = Map::new();
|
||||
let effective_model = self
|
||||
.response_metadata
|
||||
.effective_model
|
||||
.as_deref()
|
||||
.filter(|model| !model.is_empty())
|
||||
.unwrap_or(&self.requested_model);
|
||||
if !effective_model.is_empty() {
|
||||
metadata.insert(
|
||||
"effectiveModel".to_owned(),
|
||||
Value::String(effective_model.to_owned()),
|
||||
);
|
||||
}
|
||||
metadata.insert(
|
||||
"modelsEtag".to_owned(),
|
||||
self.response_metadata
|
||||
.models_etag
|
||||
.as_ref()
|
||||
.map_or(Value::Null, |etag| Value::String(etag.clone())),
|
||||
);
|
||||
metadata.insert(
|
||||
"reasoningIncluded".to_owned(),
|
||||
Value::Bool(self.response_metadata.reasoning_included),
|
||||
);
|
||||
metadata.insert("stream".to_owned(), Value::Bool(self.stream));
|
||||
metadata.insert("compact".to_owned(), Value::Bool(self.compact));
|
||||
metadata.insert("attemptIndex".to_owned(), json!(self.attempt_index));
|
||||
metadata.insert(
|
||||
"attemptAccountId".to_owned(),
|
||||
Value::String(self.account_id.clone()),
|
||||
);
|
||||
metadata.insert(
|
||||
"rateLimitHeaders".to_owned(),
|
||||
json!(self.rate_limit_headers),
|
||||
);
|
||||
metadata.insert(
|
||||
"upstreamTraceHeaders".to_owned(),
|
||||
json!(safe_observation_headers(&self.diagnostics.trace_headers)),
|
||||
);
|
||||
metadata.insert("requestSummary".to_owned(), self.request_summary.clone());
|
||||
if let Some(service_tier) = &self.service_tier {
|
||||
metadata.insert(
|
||||
"serviceTier".to_owned(),
|
||||
Value::String(service_tier.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(status) = self.diagnostics.status_code {
|
||||
metadata.insert("upstreamStatus".to_owned(), json!(status));
|
||||
}
|
||||
if let Some(decision) = self.metrics.decision {
|
||||
metadata.insert(
|
||||
"transportDecision".to_owned(),
|
||||
Value::String(decision.as_str().to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(decision) = self.websocket_pool_decision {
|
||||
metadata.insert(
|
||||
"websocketPool".to_owned(),
|
||||
json!({ "kind": decision.kind() }),
|
||||
);
|
||||
}
|
||||
if let Some(version) = self.metrics.http_version.as_deref() {
|
||||
metadata.insert("httpVersion".to_owned(), Value::String(version.to_owned()));
|
||||
}
|
||||
if let Some((_, cf_ray)) = self
|
||||
.diagnostics
|
||||
.trace_headers
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("cf-ray"))
|
||||
&& let Some((_, cf_ray)) = safe_observation_header("cf-ray", cf_ray)
|
||||
{
|
||||
metadata.insert("cfRay".to_owned(), Value::String(cf_ray));
|
||||
}
|
||||
insert_optional_metadata_millis(
|
||||
&mut metadata,
|
||||
"transportDecisionWaitMs",
|
||||
self.timings.transport_decision_wait_ms,
|
||||
);
|
||||
insert_optional_metadata_millis(&mut metadata, "wsConnectMs", self.timings.connect_ms);
|
||||
insert_optional_metadata_millis(
|
||||
&mut metadata,
|
||||
"upstreamHeadersMs",
|
||||
self.timings.headers_ms,
|
||||
);
|
||||
insert_optional_metadata_millis(&mut metadata, "firstEventMs", self.timings.first_event_ms);
|
||||
insert_optional_metadata_millis(
|
||||
&mut metadata,
|
||||
"firstReasoningMs",
|
||||
self.timings.first_reasoning_ms,
|
||||
);
|
||||
insert_optional_metadata_millis(&mut metadata, "firstTextMs", self.timings.first_text_ms);
|
||||
insert_optional_metadata_millis(&mut metadata, "firstTokenMs", self.timings.first_token_ms);
|
||||
insert_optional_metadata_millis(
|
||||
&mut metadata,
|
||||
"openaiProcessingMs",
|
||||
self.timings.provider_processing_ms,
|
||||
);
|
||||
if let Some(terminal) = self.terminal {
|
||||
let incomplete = terminal == OpenAiResponseTerminal::Incomplete;
|
||||
metadata.insert("completed".to_owned(), Value::Bool(!incomplete));
|
||||
metadata.insert("incomplete".to_owned(), Value::Bool(incomplete));
|
||||
metadata.insert("eventStatusCode".to_owned(), json!(200_u16));
|
||||
}
|
||||
ProviderResponseMetadata::new(serde_json::to_string(&Value::Object(metadata)).ok()?)
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_response_observation(
|
||||
transport: CodexBackendTransport,
|
||||
diagnostics: &CodexUpstreamDiagnostics,
|
||||
response_metadata: &CodexResponseMetadata,
|
||||
metrics: &CodexTransportMetrics,
|
||||
websocket_pool_decision: Option<crate::transport::WebSocketPoolDecision>,
|
||||
timings: ProviderResponseTimings,
|
||||
) -> Result<ProviderResponseObservation, ProviderError> {
|
||||
let mut observation = ProviderResponseObservation::new(
|
||||
UpstreamTransport::new(actual_transport_name(transport))
|
||||
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent))?,
|
||||
)
|
||||
.with_timings(ProviderResponseTimings {
|
||||
transport_decision_wait_ms: nonnegative_millis(metrics.transport_decision_wait_ms),
|
||||
connect_ms: nonnegative_millis(metrics.ws_connect_ms),
|
||||
headers_ms: nonnegative_millis(metrics.upstream_headers_ms),
|
||||
first_event_ms: nonnegative_millis(metrics.first_event_ms),
|
||||
});
|
||||
.with_timings(timings);
|
||||
if let Some(version) = metrics
|
||||
.http_version
|
||||
.as_deref()
|
||||
@@ -1097,6 +1350,7 @@ fn codex_error_observation(error: &CodexClientError) -> Option<ProviderResponseO
|
||||
&CodexResponseMetadata::default(),
|
||||
transport_metrics,
|
||||
None,
|
||||
openai_response_timings(transport_metrics, &CodexResponseMetadata::default()),
|
||||
)
|
||||
.ok()?
|
||||
.with_status_code(status.as_u16());
|
||||
@@ -1117,6 +1371,138 @@ fn codex_error_observation(error: &CodexClientError) -> Option<ProviderResponseO
|
||||
Some(observation)
|
||||
}
|
||||
|
||||
fn openai_response_timings(
|
||||
metrics: &CodexTransportMetrics,
|
||||
response_metadata: &CodexResponseMetadata,
|
||||
) -> ProviderResponseTimings {
|
||||
ProviderResponseTimings {
|
||||
transport_decision_wait_ms: nonnegative_millis(metrics.transport_decision_wait_ms),
|
||||
connect_ms: nonnegative_millis(metrics.ws_connect_ms),
|
||||
headers_ms: nonnegative_millis(metrics.upstream_headers_ms),
|
||||
first_event_ms: nonnegative_millis(metrics.first_event_ms),
|
||||
provider_processing_ms: openai_processing_ms(response_metadata),
|
||||
..ProviderResponseTimings::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_processing_ms(response_metadata: &CodexResponseMetadata) -> Option<u64> {
|
||||
response_metadata
|
||||
.client_headers
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case("openai-processing-ms"))
|
||||
.and_then(|(_, value)| value.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
fn openai_response_request_summary(
|
||||
request: &CodexResponsesRequest,
|
||||
transport: CodexBackendTransport,
|
||||
) -> Value {
|
||||
let body = request.body();
|
||||
let input = body.get("input");
|
||||
let tools = body.get("tools");
|
||||
let semantics = request.semantics();
|
||||
json!({
|
||||
"model": request.model(),
|
||||
"stream": request.stream(),
|
||||
"store": request.store(),
|
||||
"compact": semantics.compact,
|
||||
"requestKind": semantics.request_kind,
|
||||
"subagentKind": semantics.subagent_kind,
|
||||
"reasoningPreset": semantics.reasoning_preset,
|
||||
"transport": actual_transport_name(transport),
|
||||
"inputType": json_value_kind(input),
|
||||
"inputItemsCount": input.and_then(Value::as_array).map(Vec::len),
|
||||
"toolsType": json_value_kind(tools),
|
||||
"toolsCount": tools.and_then(Value::as_array).map(Vec::len),
|
||||
"topLevelFields": body.keys().cloned().collect::<Vec<_>>(),
|
||||
"previousResponseIdPresent": request.previous_response_id().is_some(),
|
||||
"serviceTier": request.service_tier(),
|
||||
"localTransport": {
|
||||
"useWebsocket": request.use_websocket,
|
||||
"forceHttpSse": request.force_http_sse,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const fn json_value_kind(value: Option<&Value>) -> &'static str {
|
||||
match value {
|
||||
None => "missing",
|
||||
Some(Value::Null) => "null",
|
||||
Some(Value::Bool(_)) => "boolean",
|
||||
Some(Value::Number(_)) => "number",
|
||||
Some(Value::String(_)) => "string",
|
||||
Some(Value::Array(_)) => "array",
|
||||
Some(Value::Object(_)) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_observation_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
|
||||
headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| safe_observation_header(name, value))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn safe_observation_header(name: &str, value: &str) -> Option<(String, String)> {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
let safe_value = SafeUpstreamValue::new(value.to_owned()).ok()?;
|
||||
ProviderResponseHeader::new(name.clone(), safe_value)?;
|
||||
Some((name, value.to_owned()))
|
||||
}
|
||||
|
||||
fn insert_first_timing(target: &mut Option<u64>, started_at: Instant) -> bool {
|
||||
if target.is_some() {
|
||||
return false;
|
||||
}
|
||||
let elapsed = u64::try_from(started_at.elapsed().as_millis())
|
||||
.unwrap_or(u64::MAX)
|
||||
.max(1);
|
||||
*target = Some(elapsed);
|
||||
true
|
||||
}
|
||||
|
||||
fn insert_optional_metadata_millis(
|
||||
metadata: &mut Map<String, Value>,
|
||||
name: &str,
|
||||
value: Option<u64>,
|
||||
) {
|
||||
if let Some(value) = value {
|
||||
metadata.insert(name.to_owned(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
async fn take_rate_limit_updates(
|
||||
updates: Option<&CodexRateLimitHeaderUpdates>,
|
||||
) -> Vec<(String, String)> {
|
||||
let Some(updates) = updates else {
|
||||
return Vec::new();
|
||||
};
|
||||
std::mem::take(&mut *updates.lock().await)
|
||||
}
|
||||
|
||||
fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
|
||||
events
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
let wire = event.wire_event()?;
|
||||
let event_type = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
match event_type {
|
||||
Some("response.incomplete") => Some(true),
|
||||
Some("response.completed") => Some(
|
||||
wire.data()
|
||||
.pointer("/response/status")
|
||||
.and_then(Value::as_str)
|
||||
== Some("incomplete"),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn synchronize_passive_quota(
|
||||
quota: &CodexCredentialQuotaService,
|
||||
account: &ProviderAccount,
|
||||
|
||||
@@ -18,7 +18,9 @@ use gateway_protocol::openai::sse::{SseEvent, SseEventDecoder, SseFrame, sse_fra
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::protocol::responses::{ResponsesSseFailure, response_event_signals};
|
||||
use super::protocol::responses::{
|
||||
ResponseEventSignals, ResponsesSseFailure, response_event_signals,
|
||||
};
|
||||
use super::usage::openai_billing_breakdown;
|
||||
|
||||
const CONTENTS_PER_OUTPUT: u32 = 1_024;
|
||||
@@ -39,6 +41,7 @@ pub struct CodexCanonicalDecoder {
|
||||
reasoning_output_seen: BTreeSet<u32>,
|
||||
usage_emitted: bool,
|
||||
semantic_output_seen: bool,
|
||||
timing_signals: ResponseEventSignals,
|
||||
raw_sse_passthrough: bool,
|
||||
}
|
||||
|
||||
@@ -132,6 +135,7 @@ impl CodexCanonicalDecoder {
|
||||
reasoning_output_seen: BTreeSet::new(),
|
||||
usage_emitted: false,
|
||||
semantic_output_seen: false,
|
||||
timing_signals: ResponseEventSignals::default(),
|
||||
raw_sse_passthrough: false,
|
||||
}
|
||||
}
|
||||
@@ -146,6 +150,7 @@ impl CodexCanonicalDecoder {
|
||||
}
|
||||
|
||||
pub fn push(&mut self, chunk: &[u8]) -> CodexCanonicalOutcome {
|
||||
self.timing_signals = ResponseEventSignals::default();
|
||||
if self.raw_sse_passthrough {
|
||||
let frames = match self.decoder.push_frames(chunk) {
|
||||
Ok(frames) => frames,
|
||||
@@ -171,6 +176,7 @@ impl CodexCanonicalDecoder {
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> CodexCanonicalOutcome {
|
||||
self.timing_signals = ResponseEventSignals::default();
|
||||
if self.raw_sse_passthrough {
|
||||
let frames = match self.decoder.finish_frames() {
|
||||
Ok(frames) => frames,
|
||||
@@ -195,6 +201,15 @@ impl CodexCanonicalDecoder {
|
||||
self.decode(events)
|
||||
}
|
||||
|
||||
/// 取走最近一次解码中由原始 Responses 事件观察到的计时语义。
|
||||
///
|
||||
/// 这份事实独立于 canonical 投影:未知的未来事件仍可保留 wire 透明转发,
|
||||
/// 同时让 Provider 正确记录首个可消费输出的时延。
|
||||
#[must_use]
|
||||
pub fn take_timing_signals(&mut self) -> ResponseEventSignals {
|
||||
std::mem::take(&mut self.timing_signals)
|
||||
}
|
||||
|
||||
fn decode(&mut self, events: Vec<SseEvent>) -> CodexCanonicalOutcome {
|
||||
let mut output = Vec::new();
|
||||
for event in events {
|
||||
@@ -254,6 +269,8 @@ impl CodexCanonicalDecoder {
|
||||
.event
|
||||
.as_deref()
|
||||
.or_else(|| value.get("type").and_then(Value::as_str));
|
||||
let signals = response_event_signals(event_type, &value);
|
||||
self.merge_timing_signals(signals);
|
||||
if matches!(event_type, Some("response.failed" | "error")) {
|
||||
let failure = ResponsesSseFailure::from_event(event_type.unwrap_or_default(), &value);
|
||||
if let Ok(wire) = Self::wire_for_event(event, value, raw_sse_frame) {
|
||||
@@ -265,7 +282,6 @@ impl CodexCanonicalDecoder {
|
||||
if let Some(event_type) = event_type {
|
||||
let _ = self.decode_event(event_type, &value, &mut canonical);
|
||||
}
|
||||
let semantic_output = response_event_signals(event_type, &value).semantic_output;
|
||||
let Ok(wire) = Self::wire_for_event(event, value, raw_sse_frame) else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -274,10 +290,17 @@ impl CodexCanonicalDecoder {
|
||||
} else {
|
||||
ProviderEvent::canonical_with_wire(canonical, wire)
|
||||
});
|
||||
self.semantic_output_seen |= semantic_output;
|
||||
self.semantic_output_seen |= signals.semantic_output;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_timing_signals(&mut self, signals: ResponseEventSignals) {
|
||||
self.timing_signals.protocol_progress |= signals.protocol_progress;
|
||||
self.timing_signals.semantic_output |= signals.semantic_output;
|
||||
self.timing_signals.reasoning_output |= signals.reasoning_output;
|
||||
self.timing_signals.text_output |= signals.text_output;
|
||||
}
|
||||
|
||||
fn wire_for_event(
|
||||
event: SseEvent,
|
||||
value: Value,
|
||||
|
||||
@@ -79,7 +79,7 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
8,
|
||||
64,
|
||||
8,
|
||||
800,
|
||||
20_000,
|
||||
5,
|
||||
true,
|
||||
true,
|
||||
|
||||
@@ -172,6 +172,46 @@ fn decoder_should_restore_done_only_reasoning_and_text_as_canonical_facts() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_timing_signals_should_count_tool_arguments_as_first_token() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_tool_timing\",\"model\":\"gpt-test\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_tool_timing\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\\\"a\\\"}\"}\n\n",
|
||||
);
|
||||
let mut decoder = CodexCanonicalDecoder::new("fallback");
|
||||
|
||||
let _ = decoder.push(body.as_bytes()).expect("tool argument frame");
|
||||
let signals = decoder.take_timing_signals();
|
||||
|
||||
assert!(signals.semantic_output);
|
||||
assert!(!signals.reasoning_output);
|
||||
assert!(!signals.text_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_timing_signals_should_distinguish_reasoning_and_text_output() {
|
||||
let body = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_output_timing\",\"model\":\"gpt-test\"}}\n\n",
|
||||
"event: response.reasoning_summary_text.delta\n",
|
||||
"data: {\"type\":\"response.reasoning_summary_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"plan\"}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"output_index\":1,\"content_index\":0,\"delta\":\"answer\"}\n\n",
|
||||
);
|
||||
let mut decoder = CodexCanonicalDecoder::new("fallback");
|
||||
|
||||
let _ = decoder.push(body.as_bytes()).expect("output frames");
|
||||
let signals = decoder.take_timing_signals();
|
||||
|
||||
assert!(signals.semantic_output);
|
||||
assert!(signals.reasoning_output);
|
||||
assert!(signals.text_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_image_tool_tokens_in_canonical_usage() {
|
||||
let body = concat!(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
//! 已有 Grok Build OAuth token 的安全归一化边界。
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
use url::Url;
|
||||
|
||||
use crate::credential::token::UnverifiedTokenSet;
|
||||
use crate::{FailureClass, GROK_CLI_BASE_URL, GrokOAuthConfig, OAuthError, SecretValue};
|
||||
use crate::{
|
||||
FailureClass, GROK_CLI_BASE_URL, GrokOAuthConfig, OAuthError, OFFICIAL_CLIENT_ID,
|
||||
OFFICIAL_SCOPES, SecretValue,
|
||||
};
|
||||
|
||||
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
|
||||
@@ -143,88 +145,37 @@ impl fmt::Debug for GrokOAuthImportEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-owned xAI OAuth 账号文档。
|
||||
/// Provider-owned xAI OAuth 账号导入。
|
||||
pub struct GrokOAuthImportDocument {
|
||||
entries: Vec<GrokOAuthImportEntry>,
|
||||
}
|
||||
|
||||
impl GrokOAuthImportDocument {
|
||||
/// 解析完整 OAuth 账号文档;未知字段、代理、API Key 或非 xAI OAuth 项均拒绝。
|
||||
/// 从外部 JSON 提取 xAI OAuth 认证字段。
|
||||
///
|
||||
/// 来源包装格式、代理、并发和其他展示 metadata 不参与认证,也不会影响导入。
|
||||
/// 实际 token 仍须通过官方 refresh/user-info 验证;API Key 不能混入 OAuth 条目。
|
||||
pub fn parse_json(document: &[u8]) -> Result<Self, GrokOAuthImportError> {
|
||||
if document.is_empty() || document.len() > MAX_IMPORT_DOCUMENT_BYTES {
|
||||
return Err(GrokOAuthImportError::InvalidField("document"));
|
||||
}
|
||||
let wire: OAuthAccountDocumentWire = serde_json::from_slice(document)
|
||||
let wire: Value = serde_json::from_slice(document)
|
||||
.map_err(|_| GrokOAuthImportError::InvalidField("document"))?;
|
||||
if wire
|
||||
.kind
|
||||
.as_deref()
|
||||
.is_some_and(|kind| kind != "external-account-bundle")
|
||||
|| wire.version.is_some_and(|version| version != 1)
|
||||
|| !wire.proxies.is_empty()
|
||||
|| wire.accounts.is_empty()
|
||||
|| wire.accounts.len() > MAX_IMPORT_ACCOUNTS
|
||||
{
|
||||
let accounts = import_accounts(&wire)?;
|
||||
if accounts.is_empty() || accounts.len() > MAX_IMPORT_ACCOUNTS {
|
||||
return Err(GrokOAuthImportError::InvalidField("document"));
|
||||
}
|
||||
let mut names = HashSet::new();
|
||||
let mut entries = Vec::with_capacity(wire.accounts.len());
|
||||
for account in wire.accounts {
|
||||
if account.credentials.extra_fields.keys().any(|field| {
|
||||
matches!(
|
||||
field.as_str(),
|
||||
"api_key" | "apiKey" | "xai_api_key" | "xaiApiKey"
|
||||
)
|
||||
}) {
|
||||
return Err(GrokOAuthImportError::InvalidField("account"));
|
||||
let now = Utc::now();
|
||||
let mut entries = Vec::with_capacity(accounts.len());
|
||||
for (index, account) in accounts.into_iter().enumerate() {
|
||||
if let Some(entry) = parse_account_entry(account, index, now)? {
|
||||
entries.push(entry);
|
||||
}
|
||||
if account.platform != "grok"
|
||||
|| account.kind != "oauth"
|
||||
|| account.concurrency == 0
|
||||
|| account.priority == 0
|
||||
|| !valid_display_value(&account.name, MAX_ACCOUNT_NAME_BYTES)
|
||||
|| !names.insert(account.name.clone())
|
||||
{
|
||||
return Err(GrokOAuthImportError::InvalidField("account"));
|
||||
}
|
||||
let email = match (account.credentials.email, account.extra.email) {
|
||||
(Some(left), Some(right)) if left != right => {
|
||||
return Err(GrokOAuthImportError::InvalidField("email"));
|
||||
}
|
||||
(Some(email), _) | (_, Some(email)) => Some(email),
|
||||
(None, None) => None,
|
||||
};
|
||||
if email
|
||||
.as_deref()
|
||||
.is_some_and(|value| !valid_display_value(value, MAX_EMAIL_BYTES))
|
||||
{
|
||||
return Err(GrokOAuthImportError::InvalidField("email"));
|
||||
}
|
||||
let tokens = match account.credentials.id_token {
|
||||
Some(id_token) => GrokOAuthImportTokens::new(
|
||||
SecretValue::new(account.credentials.access_token),
|
||||
SecretValue::new(account.credentials.refresh_token),
|
||||
SecretValue::new(id_token),
|
||||
),
|
||||
None => GrokOAuthImportTokens::without_id_token(
|
||||
SecretValue::new(account.credentials.access_token),
|
||||
SecretValue::new(account.credentials.refresh_token),
|
||||
),
|
||||
};
|
||||
let metadata = GrokOAuthImportMetadata::new(
|
||||
account.credentials.token_type,
|
||||
account.credentials.client_id,
|
||||
account.credentials.scope,
|
||||
account.credentials.base_url,
|
||||
wire.exported_at,
|
||||
account.credentials.expires_at,
|
||||
);
|
||||
entries.push(GrokOAuthImportEntry {
|
||||
name: account.name,
|
||||
email,
|
||||
candidate: GrokOAuthImportCandidate::new(tokens, metadata),
|
||||
});
|
||||
}
|
||||
if entries.is_empty() {
|
||||
return Err(GrokOAuthImportError::InvalidField("document"));
|
||||
}
|
||||
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
@@ -244,66 +195,121 @@ impl fmt::Debug for GrokOAuthImportDocument {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct OAuthAccountDocumentWire {
|
||||
#[serde(default, rename = "type")]
|
||||
kind: Option<String>,
|
||||
#[serde(default)]
|
||||
version: Option<u32>,
|
||||
exported_at: DateTime<Utc>,
|
||||
accounts: Vec<OAuthAccountWire>,
|
||||
proxies: Vec<Value>,
|
||||
#[serde(default, rename = "skipped_shadows")]
|
||||
_skipped_shadows: u64,
|
||||
fn import_accounts(document: &Value) -> Result<Vec<&Value>, GrokOAuthImportError> {
|
||||
match document {
|
||||
Value::Array(accounts) => Ok(accounts.iter().collect()),
|
||||
Value::Object(object) => match object.get("accounts") {
|
||||
Some(Value::Array(accounts)) => Ok(accounts.iter().collect()),
|
||||
Some(_) => Err(GrokOAuthImportError::InvalidField("accounts")),
|
||||
None => Ok(vec![document]),
|
||||
},
|
||||
_ => Err(GrokOAuthImportError::InvalidField("document")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct OAuthAccountWire {
|
||||
name: String,
|
||||
#[serde(default, rename = "notes")]
|
||||
_notes: Option<String>,
|
||||
platform: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
credentials: OAuthCredentialsWire,
|
||||
concurrency: u32,
|
||||
priority: u32,
|
||||
#[serde(default, rename = "rate_multiplier")]
|
||||
_rate_multiplier: Option<Value>,
|
||||
#[serde(default, rename = "expires_at")]
|
||||
_expires_at: Option<i64>,
|
||||
#[serde(default, rename = "auto_pause_on_expired")]
|
||||
_auto_pause_on_expired: Option<bool>,
|
||||
#[serde(default, rename = "proxy_key")]
|
||||
_proxy_key: Option<String>,
|
||||
#[serde(default)]
|
||||
extra: OAuthAccountExtraWire,
|
||||
fn parse_account_entry(
|
||||
account: &Value,
|
||||
index: usize,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<GrokOAuthImportEntry>, GrokOAuthImportError> {
|
||||
let account = account
|
||||
.as_object()
|
||||
.ok_or(GrokOAuthImportError::InvalidField("account"))?;
|
||||
let credentials = account
|
||||
.get("credentials")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or(account);
|
||||
let (access_token, refresh_token) = match (
|
||||
credential_value(credentials, &["access_token", "accessToken", "at"]),
|
||||
credential_value(credentials, &["refresh_token", "refreshToken", "rt"]),
|
||||
) {
|
||||
(Some(access_token), Some(refresh_token)) => (access_token, refresh_token),
|
||||
(None, None) => return Ok(None),
|
||||
(None, Some(_)) => return Err(GrokOAuthImportError::InvalidField("access_token")),
|
||||
(Some(_), None) => return Err(GrokOAuthImportError::InvalidField("refresh_token")),
|
||||
};
|
||||
if ["api_key", "apiKey", "xai_api_key", "xaiApiKey"]
|
||||
.iter()
|
||||
.any(|field| credentials.contains_key(*field))
|
||||
{
|
||||
return Err(GrokOAuthImportError::InvalidField("account"));
|
||||
}
|
||||
|
||||
let email = [
|
||||
credential_value(credentials, &["email"]),
|
||||
display_value(account, &["email"]),
|
||||
account
|
||||
.get("extra")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|extra| display_value(extra, &["email"])),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|value| valid_display_value(value, MAX_EMAIL_BYTES))
|
||||
.map(str::to_owned);
|
||||
let name = display_value(account, &["name", "label"])
|
||||
.filter(|value| valid_display_value(value, MAX_ACCOUNT_NAME_BYTES))
|
||||
.map(str::to_owned)
|
||||
.or_else(|| email.clone())
|
||||
.unwrap_or_else(|| format!("xAI OAuth account {}", index + 1));
|
||||
let expires_at = credential_value(
|
||||
credentials,
|
||||
&[
|
||||
"expires_at",
|
||||
"expiresAt",
|
||||
"access_token_expires_at",
|
||||
"accessTokenExpiresAt",
|
||||
],
|
||||
)
|
||||
.and_then(parse_source_expiry)
|
||||
.filter(|expires_at| *expires_at > now && *expires_at - now <= MAX_DECLARED_LIFETIME)
|
||||
.unwrap_or_else(|| now + ChronoDuration::seconds(1));
|
||||
let tokens = match credential_value(credentials, &["id_token", "idToken"]) {
|
||||
Some(id_token) => GrokOAuthImportTokens::new(
|
||||
SecretValue::new(access_token.to_owned()),
|
||||
SecretValue::new(refresh_token.to_owned()),
|
||||
SecretValue::new(id_token.to_owned()),
|
||||
),
|
||||
None => GrokOAuthImportTokens::without_id_token(
|
||||
SecretValue::new(access_token.to_owned()),
|
||||
SecretValue::new(refresh_token.to_owned()),
|
||||
),
|
||||
};
|
||||
let metadata = GrokOAuthImportMetadata::new(
|
||||
"Bearer".to_owned(),
|
||||
OFFICIAL_CLIENT_ID.to_owned(),
|
||||
OFFICIAL_SCOPES.join(" "),
|
||||
GROK_CLI_BASE_URL.to_owned(),
|
||||
now,
|
||||
expires_at,
|
||||
);
|
||||
|
||||
Ok(Some(GrokOAuthImportEntry {
|
||||
name,
|
||||
email,
|
||||
candidate: GrokOAuthImportCandidate::new(tokens, metadata),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthCredentialsWire {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
#[serde(default)]
|
||||
id_token: Option<String>,
|
||||
token_type: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
email: Option<String>,
|
||||
base_url: String,
|
||||
client_id: String,
|
||||
scope: String,
|
||||
#[serde(flatten)]
|
||||
extra_fields: HashMap<String, Value>,
|
||||
fn credential_value<'a>(credentials: &'a Map<String, Value>, fields: &[&str]) -> Option<&'a str> {
|
||||
display_value(credentials, fields).or_else(|| {
|
||||
credentials
|
||||
.get("token")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|token| display_value(token, fields))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct OAuthAccountExtraWire {
|
||||
#[serde(default)]
|
||||
email: Option<String>,
|
||||
#[serde(flatten)]
|
||||
_extra_fields: HashMap<String, Value>,
|
||||
fn display_value<'a>(object: &'a Map<String, Value>, fields: &[&str]) -> Option<&'a str> {
|
||||
fields
|
||||
.iter()
|
||||
.find_map(|field| object.get(*field).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
fn parse_source_expiry(value: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
impl GrokOAuthImportCandidate {
|
||||
|
||||
@@ -14,7 +14,8 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use gateway_core::engine::provider::{
|
||||
EventStream, Provider, ProviderCallMetadata, ProviderCatalogGeneration,
|
||||
ProviderModelCapabilities, ProviderRequest, ProviderStream, UpstreamTransport,
|
||||
ProviderModelCapabilities, ProviderRequest, ProviderRequestObservation, ProviderStream,
|
||||
UpstreamTransport,
|
||||
};
|
||||
use gateway_core::engine::{AttemptContext, ContinuationAttempt, UpstreamSendState};
|
||||
use gateway_core::error::{ContinuationFailure, ProviderError, ProviderErrorKind};
|
||||
@@ -114,6 +115,26 @@ impl Provider for GrokBuildProvider {
|
||||
self.catalog.catalog_generation()
|
||||
}
|
||||
|
||||
fn request_observation(&self, operation: &Operation) -> ProviderRequestObservation {
|
||||
let Operation::Generate(request) = operation else {
|
||||
return ProviderRequestObservation::default();
|
||||
};
|
||||
let reasoning_effort = request
|
||||
.protocol_payload()
|
||||
.body()
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned);
|
||||
ProviderRequestObservation {
|
||||
reasoning_effort,
|
||||
..ProviderRequestObservation::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_model_capabilities(
|
||||
&self,
|
||||
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
|
||||
|
||||
@@ -237,49 +237,53 @@ fn oauth_account_document_should_reject_api_key_credential_field() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_account_document_should_accept_optional_header_and_dynamic_provider_fields() {
|
||||
fn oauth_account_document_should_ignore_source_wrapper_and_non_auth_metadata() {
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
|
||||
document["type"] = serde_json::Value::String("external-account-bundle".to_owned());
|
||||
document["version"] = serde_json::Value::from(1);
|
||||
document["skipped_shadows"] = serde_json::Value::from(2);
|
||||
document["type"] = serde_json::Value::String("sub2api-data".to_owned());
|
||||
document["version"] = serde_json::Value::from(99);
|
||||
document["proxies"] = serde_json::json!([{"url": "http://127.0.0.1:8080"}]);
|
||||
document["source_extension"] = serde_json::json!({"opaque": true});
|
||||
document["accounts"][0]["platform"] = serde_json::Value::String("source-platform".to_owned());
|
||||
document["accounts"][0]["type"] = serde_json::Value::String("source-auth".to_owned());
|
||||
document["accounts"][0]["concurrency"] = serde_json::Value::from(0);
|
||||
document["accounts"][0]["priority"] = serde_json::Value::from(0);
|
||||
document["accounts"][0]["credentials"]["provider_extension"] =
|
||||
serde_json::json!({"window": "dynamic"});
|
||||
document["accounts"][0]["extra"]["provider_snapshot"] = serde_json::json!({"remaining": 1});
|
||||
document["accounts"][0]["extra"] = serde_json::json!({
|
||||
"email": "different@example.test",
|
||||
"provider_snapshot": {"remaining": 1}
|
||||
});
|
||||
|
||||
let parsed = GrokOAuthImportDocument::parse_json(
|
||||
&serde_json::to_vec(&document).expect("serialize fixture"),
|
||||
)
|
||||
.expect("official optional fields");
|
||||
.expect("source wrapper must not constrain xAI OAuth import");
|
||||
|
||||
assert_eq!(parsed.into_entries().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_oauth_account_document_should_reject_proxy_and_identity_mismatch() {
|
||||
let mut proxy_document: serde_json::Value =
|
||||
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
|
||||
proxy_document["proxies"] = serde_json::json!([{"url": "http://127.0.0.1:8080"}]);
|
||||
assert!(
|
||||
GrokOAuthImportDocument::parse_json(
|
||||
&serde_json::to_vec(&proxy_document).expect("serialize fixture")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
fn minimal_oauth_credential_should_not_require_a_source_format() {
|
||||
let document = serde_json::json!({
|
||||
"access_token": "fixture-access-token",
|
||||
"refresh_token": "fixture-refresh-token",
|
||||
"expires_at": Utc::now() + Duration::hours(1)
|
||||
});
|
||||
|
||||
let mut mismatch_document: serde_json::Value =
|
||||
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
|
||||
mismatch_document["accounts"][0]["extra"]["email"] =
|
||||
serde_json::Value::String("different@example.test".to_owned());
|
||||
let error = GrokOAuthImportDocument::parse_json(
|
||||
&serde_json::to_vec(&mismatch_document).expect("serialize fixture"),
|
||||
let mut entries = GrokOAuthImportDocument::parse_json(
|
||||
&serde_json::to_vec(&document).expect("serialize fixture"),
|
||||
)
|
||||
.expect_err("identity mismatch must fail closed");
|
||||
assert!(matches!(error, GrokOAuthImportError::InvalidField("email")));
|
||||
.expect("minimal credential")
|
||||
.into_entries();
|
||||
|
||||
let entry = entries.pop().expect("one entry");
|
||||
assert_eq!(entry.name(), "xAI OAuth account 1");
|
||||
assert_eq!(entry.email(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_oauth_account_document_should_reject_duplicate_account_names() {
|
||||
fn oauth_account_document_should_not_require_unique_source_names() {
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_slice(&oauth_account_document()).expect("fixture JSON");
|
||||
let duplicate = document["accounts"][0].clone();
|
||||
@@ -288,14 +292,12 @@ fn strict_oauth_account_document_should_reject_duplicate_account_names() {
|
||||
.expect("accounts array")
|
||||
.push(duplicate);
|
||||
|
||||
let error = GrokOAuthImportDocument::parse_json(
|
||||
let parsed = GrokOAuthImportDocument::parse_json(
|
||||
&serde_json::to_vec(&document).expect("serialize fixture"),
|
||||
)
|
||||
.expect_err("duplicate names must be rejected");
|
||||
assert!(matches!(
|
||||
error,
|
||||
GrokOAuthImportError::InvalidField("account")
|
||||
));
|
||||
.expect("upstream identity verification owns duplicate detection");
|
||||
|
||||
assert_eq!(parsed.into_entries().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -493,6 +493,19 @@ fn operation() -> Operation {
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
}
|
||||
|
||||
fn operation_with_reasoning_effort(effort: &str) -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("client-model")),
|
||||
("input".to_owned(), json!("hello")),
|
||||
("reasoning".to_owned(), json!({"effort": effort})),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload");
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
}
|
||||
|
||||
fn compaction_operation() -> Operation {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
@@ -628,6 +641,19 @@ fn context_with_continuation_attempt(
|
||||
context(CancellationToken::new(), Some(continuation)).with_continuation_attempt(attempt)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_observation_preserves_raw_xai_reasoning_effort() {
|
||||
let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await;
|
||||
|
||||
let observation =
|
||||
provider.request_observation(&operation_with_reasoning_effort("future-value"));
|
||||
|
||||
assert_eq!(
|
||||
observation.reasoning_effort.as_deref(),
|
||||
Some("future-value")
|
||||
);
|
||||
}
|
||||
|
||||
fn operation_with_state(body: serde_json::Value, state: ProviderSessionState) -> Operation {
|
||||
let serde_json::Value::Object(body) = body else {
|
||||
panic!("request body must be an object");
|
||||
|
||||
@@ -213,6 +213,7 @@ create table model_requests (
|
||||
upstream_transport text,
|
||||
http_version text,
|
||||
websocket_pool text,
|
||||
provider_observation_json jsonb,
|
||||
attempt_count integer not null default 0,
|
||||
upstream_send_state text not null default 'not_sent',
|
||||
downstream_committed_at timestamptz,
|
||||
@@ -316,6 +317,10 @@ create table model_requests (
|
||||
constraint model_requests_websocket_pool_ck check (
|
||||
websocket_pool is null or websocket_pool in ('new', 'reuse')
|
||||
),
|
||||
constraint model_requests_provider_observation_ck check (
|
||||
provider_observation_json is null
|
||||
or jsonb_typeof(provider_observation_json) = 'object'
|
||||
),
|
||||
constraint model_requests_cost_ck check (
|
||||
cost_source in ('provider_reported', 'calculated', 'unavailable')
|
||||
and (
|
||||
|
||||
@@ -31,6 +31,8 @@ const localUsage = computed<AccountLocalUsage | null>(() => {
|
||||
return value as AccountLocalUsage
|
||||
})
|
||||
const mode = computed<UsageWindowMode>(() => {
|
||||
if (typeof props.window?.usedPercent === 'number')
|
||||
return 'quota'
|
||||
if (localUsage.value)
|
||||
return 'local'
|
||||
if (props.window)
|
||||
@@ -40,9 +42,21 @@ const mode = computed<UsageWindowMode>(() => {
|
||||
const quotaValueVisible = computed(() =>
|
||||
typeof props.window?.usedPercent === 'number' && props.window.usedPercent > 0,
|
||||
)
|
||||
const quotaLocalUsageDisplay = computed(() => {
|
||||
const display = localUsage.value?.totalTokensDisplay
|
||||
if (typeof display === 'string' && display.trim())
|
||||
return display.trim()
|
||||
const total = localUsage.value?.totalTokens
|
||||
return typeof total === 'number' && total > 0 ? total.toLocaleString() : ''
|
||||
})
|
||||
const quotaLocalUsageVisible = computed(() =>
|
||||
props.showLocalValue
|
||||
&& typeof localUsage.value?.totalTokens === 'number'
|
||||
&& localUsage.value.totalTokens > 0,
|
||||
)
|
||||
|
||||
const rootClass = computed(() =>
|
||||
isCompact.value ? 'min-w-0' : 'rounded-lg bg-(--cp-bg-subtle) p-2',
|
||||
isCompact.value ? 'flex min-w-0 flex-col' : 'rounded-lg bg-(--cp-bg-subtle) p-2',
|
||||
)
|
||||
const headerClass = computed(() =>
|
||||
isCompact.value
|
||||
@@ -59,14 +73,14 @@ const valueClass = computed(() =>
|
||||
)
|
||||
const trackShapeClass = computed(() =>
|
||||
isCompact.value
|
||||
? 'h-1.5 w-full overflow-hidden rounded-full'
|
||||
? 'h-1 w-full overflow-hidden rounded-full'
|
||||
: 'h-2 overflow-hidden rounded-full',
|
||||
)
|
||||
const trackClass = computed(() => `${trackShapeClass.value} bg-(--cp-default-border)`)
|
||||
const barStyle = computed(() => {
|
||||
if (!props.window)
|
||||
return undefined
|
||||
return quotaWindowBarStyle(props.window, isCompact.value ? '6px' : '8px')
|
||||
return quotaWindowBarStyle(props.window, isCompact.value ? '4px' : '8px')
|
||||
})
|
||||
const barClass = computed(() => props.window ? quotaWindowBarClass(props.window) : undefined)
|
||||
const percentTextClass = computed(() =>
|
||||
@@ -131,16 +145,24 @@ const requestBars = computed(() => {
|
||||
<span class="min-w-0" :class="labelClass">
|
||||
{{ window.labelDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="quotaValueVisible"
|
||||
class="shrink-0 font-mono tabular-nums"
|
||||
:class="[valueClass, percentTextClass]"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
<span class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums">
|
||||
<span
|
||||
v-if="quotaLocalUsageVisible"
|
||||
class="text-(--cp-text-muted)"
|
||||
:class="valueClass"
|
||||
>
|
||||
{{ quotaLocalUsageDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="quotaValueVisible"
|
||||
:class="[valueClass, percentTextClass]"
|
||||
>
|
||||
{{ window.usedPercentDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[trackClass, !isCompact ? 'mt-2' : undefined]"
|
||||
:class="[trackClass, !isCompact ? 'mt-2' : 'mt-auto']"
|
||||
role="progressbar"
|
||||
:aria-label="window.labelDisplay"
|
||||
aria-valuemin="0"
|
||||
@@ -176,7 +198,7 @@ const requestBars = computed(() => {
|
||||
</div>
|
||||
<div
|
||||
class="flex items-stretch gap-px"
|
||||
:class="[trackShapeClass, !isCompact ? 'mt-2' : undefined]"
|
||||
:class="[trackShapeClass, !isCompact ? 'mt-2' : 'mt-auto']"
|
||||
role="img"
|
||||
:aria-label="requestTimelineTitle"
|
||||
:title="requestTimelineTitle"
|
||||
|
||||
@@ -280,6 +280,7 @@ const valueToneClasses: Record<string, string> = {
|
||||
:window="account.usageWindow"
|
||||
:show-local-value="false"
|
||||
variant="compact"
|
||||
class="xl:h-7.25"
|
||||
/>
|
||||
</span>
|
||||
</article>
|
||||
|
||||
@@ -41,6 +41,9 @@ const props = withDefaults(
|
||||
const activeView = shallowRef('cost')
|
||||
const { palette } = useUsageChartPalette()
|
||||
const points = computed<any[]>(() => props.cost.points)
|
||||
const hasStandardCost = computed(() =>
|
||||
points.value.some(point => point.standardCost != null),
|
||||
)
|
||||
|
||||
const viewOptions = [
|
||||
{ label: '费用', value: 'cost' },
|
||||
@@ -101,7 +104,7 @@ function legendNames() {
|
||||
return ['未缓存输入', '缓存输入', '输出']
|
||||
if (activeView.value === 'cache')
|
||||
return ['缓存 Token 占比', '命中请求率']
|
||||
return ['预估费用', '标准费用']
|
||||
return hasStandardCost.value ? ['预估费用', '标准费用'] : ['预估费用']
|
||||
}
|
||||
|
||||
function axisFormatter() {
|
||||
@@ -152,7 +155,7 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
const series = [
|
||||
lineSeries(
|
||||
'预估费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
|
||||
@@ -160,12 +163,17 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
lineSeries(
|
||||
'标准费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.standardCost)),
|
||||
theme.textMuted,
|
||||
),
|
||||
]
|
||||
if (hasStandardCost.value) {
|
||||
series.push(
|
||||
lineSeries(
|
||||
'标准费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.standardCost)),
|
||||
theme.textMuted,
|
||||
),
|
||||
)
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
function lineSeries(
|
||||
@@ -230,11 +238,13 @@ function formatTooltip(params: unknown) {
|
||||
].join('<br/>')
|
||||
}
|
||||
|
||||
return [
|
||||
const lines = [
|
||||
title,
|
||||
`预估费用: ${formatUsd(point.estimatedCost)}`,
|
||||
`标准费用: ${formatUsd(point.standardCost)}`,
|
||||
].join('<br/>')
|
||||
]
|
||||
if (point.standardCost != null)
|
||||
lines.push(`标准费用: ${formatUsd(point.standardCost)}`)
|
||||
return lines.join('<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))
|
||||
<div
|
||||
class="grid grid-cols-[auto_auto] items-center justify-end gap-x-2 gap-y-1.5 font-mono text-[12px] leading-none font-[760] tabular-nums"
|
||||
>
|
||||
<span class="text-[11px] text-(--cp-text-muted)">首字</span>
|
||||
<span class="text-(--cp-text-secondary)">{{ latencyDetails.firstTokenDisplay }}</span>
|
||||
<span class="text-[11px] text-(--cp-text-muted)">{{ latencyDetails.firstOutputLabel }}</span>
|
||||
<span class="text-(--cp-text-secondary)">{{ latencyDetails.firstOutputDisplay }}</span>
|
||||
<span class="text-[11px] text-(--cp-text-muted)">总耗时</span>
|
||||
<span class="text-(--cp-text-primary)">{{ latencyDetails.totalDisplay }}</span>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
usageBilling,
|
||||
usageBillingText,
|
||||
usageClientIp,
|
||||
usageLatencyDetails,
|
||||
usageModelDisplay,
|
||||
usageReasoningEffort,
|
||||
usageRecordType,
|
||||
@@ -40,6 +41,7 @@ const modelDisplay = computed(() => props.record
|
||||
: { primary: '—', secondary: '' })
|
||||
const tokenDetails = computed(() => props.record ? usageTokenDetails(props.record) : null)
|
||||
const billing = computed(() => props.record ? usageBilling(props.record) : null)
|
||||
const latencyDetails = computed(() => props.record ? usageLatencyDetails(props.record) : null)
|
||||
|
||||
const panelClass = 'rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5'
|
||||
const panelTitleClass = 'm-0 text-[12px] leading-none font-[780] text-(--cp-text-secondary)'
|
||||
@@ -55,7 +57,11 @@ const overviewItems = computed(() => [
|
||||
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
|
||||
{ label: '类型', value: props.record ? usageRecordType(props.record) : '—' },
|
||||
{ label: '耗时', value: props.record?.latencyMsDisplay, mono: true },
|
||||
{ label: '首字', value: props.record?.firstTokenLatencyMsDisplay, mono: true },
|
||||
{
|
||||
label: latencyDetails.value?.firstOutputLabel ?? '首字',
|
||||
value: latencyDetails.value?.firstOutputDisplay ?? '—',
|
||||
mono: true,
|
||||
},
|
||||
{ label: '总 Token', value: tokenDetails.value?.totalTokensDisplay, mono: true },
|
||||
{ label: '请求 ID', value: props.record?.requestId, mono: true, wide: true },
|
||||
{ label: '消息', value: props.record?.message, wide: true },
|
||||
|
||||
@@ -249,6 +249,7 @@ export function usageLatencyDetails(record: UsageDisplayRecord) {
|
||||
const firstTokenMs = durationValue(
|
||||
record.firstTokenLatencyMs ?? property(latencyDetails, 'firstTokenMs'),
|
||||
)
|
||||
const firstEventMs = durationValue(property(latencyDetails, 'firstEventMs'))
|
||||
const totalMs = durationValue(record.latencyMs)
|
||||
const firstReasoningMs = durationValue(property(latencyDetails, 'firstReasoningMs'))
|
||||
const firstTextMs = durationValue(property(latencyDetails, 'firstTextMs'))
|
||||
@@ -282,14 +283,16 @@ export function usageLatencyDetails(record: UsageDisplayRecord) {
|
||||
},
|
||||
{ label: 'WebSocket 连接', value: durationValue(property(latencyDetails, 'wsConnectMs')) },
|
||||
{ label: '上游响应头', value: durationValue(property(latencyDetails, 'upstreamHeadersMs')) },
|
||||
{ label: '首个上游事件', value: durationValue(property(latencyDetails, 'firstEventMs')) },
|
||||
{ label: '首个上游事件', value: firstEventMs },
|
||||
{ label: '上游处理', value: durationValue(property(latencyDetails, 'openaiProcessingMs')) },
|
||||
]
|
||||
.filter(item => item.value !== null)
|
||||
.map(item => ({ ...item, value: formatDuration(item.value) }))
|
||||
|
||||
return {
|
||||
firstTokenDisplay: formatDuration(firstTokenMs),
|
||||
// SSE 可能先收到生命周期事件而没有文本或推理增量;这不是首字,须保留原始语义。
|
||||
firstOutputLabel: firstTokenMs === null && firstEventMs !== null ? '首事件' : '首字',
|
||||
firstOutputDisplay: formatDuration(firstTokenMs ?? firstEventMs),
|
||||
totalDisplay: formatDuration(totalMs),
|
||||
breakdownItems,
|
||||
transportItems,
|
||||
@@ -386,8 +389,10 @@ function recordMetadata(record: UsageDisplayRecord) {
|
||||
}
|
||||
|
||||
function recordLatencyDetails(record: UsageDisplayRecord) {
|
||||
const metadata = recordMetadata(record)
|
||||
return asRecord('latencyDetails' in record ? record.latencyDetails : undefined)
|
||||
?? recordMetadata(record)
|
||||
?? asRecord(property(metadata, 'latencyDetails'))
|
||||
?? metadata
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
Reference in New Issue
Block a user