mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 08:06:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c10676764 | ||
|
|
caa6c8ffc8 | ||
|
|
9d651856be | ||
|
|
d10b8890e0 | ||
|
|
d97c6e064b | ||
|
|
f85801edb3 | ||
|
|
e30aad4755 | ||
|
|
945f7912ac | ||
|
|
19038f264a | ||
|
|
71c3b03878 |
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: Option<String>,
|
||||
/// 账号当前套餐,不属于请求历史快照。
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -757,11 +760,20 @@ pub struct UsageSummary {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -796,6 +809,8 @@ pub struct OpsError {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -1142,6 +1157,8 @@ pub struct UsageInsights {
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
|
||||
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
let remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.zip(remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::model::{
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -444,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ impl DefaultObservabilityService {
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_list_billing(&mut observation.recent_requests);
|
||||
self.enrich_list_records(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
for account in &mut observation.account_usage {
|
||||
@@ -268,7 +268,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_list_billing(&mut page.items);
|
||||
self.enrich_list_records(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -334,29 +334,37 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError> {
|
||||
let items = self
|
||||
let observation = self
|
||||
.store
|
||||
.usage_diagnostics(range, filter, dimension)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
let items = items
|
||||
let items = observation
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|
||||
|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, item.account_plan_type.as_deref())
|
||||
},
|
||||
),
|
||||
account_plan_type: item.account_plan_type,
|
||||
request_count: item.request_count,
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate,
|
||||
request_share: rate_or_zero(item.request_count, total_requests),
|
||||
request_share: rate_or_zero(
|
||||
item.request_count,
|
||||
observation.total_request_count,
|
||||
),
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
@@ -374,10 +382,19 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
}
|
||||
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
|
||||
self.store
|
||||
let mut page = self
|
||||
.store
|
||||
.list_ops_errors(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "operations errors"))
|
||||
.map_err(|error| map_store_error(error, "operations errors"))?;
|
||||
for error in &mut page.items {
|
||||
error.provider_account_plan_type_display =
|
||||
error.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, error.provider_account_plan_type.as_deref())
|
||||
});
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,10 +789,15 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
|
||||
for record in records {
|
||||
record.provider_account_plan_type_display =
|
||||
record.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
|
||||
});
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -114,18 +114,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
|
||||
let [week, month] = forecast("a(vec![window("week", 7)]));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert!(!week.extrapolated);
|
||||
assert!(month.extrapolated);
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,8 +199,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(!week.low_sample);
|
||||
assert_eq!(week.remaining_tokens, Some(0));
|
||||
assert_eq!(week.remaining_usd, Some(0.0));
|
||||
assert_eq!(week.estimated_tokens, Some(1_000));
|
||||
assert_eq!(week.estimated_usd, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,8 +253,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(9_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.estimated_tokens, Some(38_571));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
@@ -273,7 +268,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
@@ -308,13 +302,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
@@ -331,11 +324,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -352,7 +341,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,8 +358,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,11 +406,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
|
||||
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
|
||||
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
|
||||
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
|
||||
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
|
||||
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
|
||||
assert!(
|
||||
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
|
||||
);
|
||||
@@ -445,6 +428,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
|
||||
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert_eq!(cycle.estimated_tokens, Some(1_000));
|
||||
assert_eq!(cycle.estimated_usd, Some(2.0));
|
||||
assert_eq!(cycle.remaining_tokens, Some(0));
|
||||
assert_eq!(cycle.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
@@ -2430,7 +2430,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
.unwrap();
|
||||
assert!(result.forecasts[0].unavailable_reason.is_none());
|
||||
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added);
|
||||
store
|
||||
.quota_forecast_history
|
||||
@@ -2471,7 +2470,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
let cycle = &result.forecasts[0];
|
||||
assert!(cycle.unavailable_reason.is_none());
|
||||
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
|
||||
assert_eq!(cycle.remaining_tokens, Some(429));
|
||||
assert_eq!(cycle.estimated_tokens, Some(679));
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ use gateway_admin::{
|
||||
observability::{
|
||||
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
|
||||
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
@@ -770,7 +770,7 @@ impl ObservabilityStore for UnavailableStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ use gateway_admin::{
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
|
||||
UsagePage, UsageQuery, china_day_start,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
|
||||
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
|
||||
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -421,7 +421,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
.expect("calculated total"),
|
||||
},
|
||||
}]);
|
||||
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let insights = services
|
||||
@@ -513,7 +516,10 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
let mut failure = diagnostic("single_failure", 1);
|
||||
failure.success_count = 0;
|
||||
failure.failure_count = 1;
|
||||
store.replace_diagnostics(vec![success, failure]);
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 101,
|
||||
items: vec![success, failure],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
@@ -529,6 +535,68 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
assert_eq!(result.items[1].error_rate, 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_use_the_total_before_truncating_groups() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 200,
|
||||
items: (0..100)
|
||||
.map(|index| diagnostic(&format!("model-{index}"), 1))
|
||||
.collect(),
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("limited diagnostics");
|
||||
|
||||
assert!(result.items.iter().all(|item| item.request_share == 0.005));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_count_each_retried_request_once() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let mut item = diagnostic("model", 8);
|
||||
item.attempt_count = 14;
|
||||
item.retry_count = 6;
|
||||
item.retried_request_count = 2;
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
total_request_count: 8,
|
||||
items: vec![item],
|
||||
});
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.expect("retry diagnostics");
|
||||
|
||||
assert_eq!(
|
||||
(result.items[0].retry_count, result.items[0].retry_rate),
|
||||
(6, 0.25)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_preserve_empty_results() {
|
||||
let range = observation_range(Utc::now());
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let result = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
|
||||
.await
|
||||
.expect("empty diagnostics");
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
|
||||
let now = Utc::now();
|
||||
@@ -632,7 +700,7 @@ struct FixtureObservabilityStore {
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
billing_stream_fails: AtomicBool,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
diagnostics: Mutex<DiagnosticsObservation>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
@@ -653,7 +721,7 @@ impl FixtureObservabilityStore {
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
billing_stream_fails: AtomicBool::new(false),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(DiagnosticsObservation::default()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
summary_observed_at: Mutex::new(None),
|
||||
slots_observed_at: Mutex::new(None),
|
||||
@@ -686,7 +754,7 @@ impl FixtureObservabilityStore {
|
||||
.expect("calculated billing facts") = facts;
|
||||
}
|
||||
|
||||
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
|
||||
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
|
||||
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
|
||||
}
|
||||
|
||||
@@ -798,7 +866,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
@@ -948,6 +1016,8 @@ fn total_record(
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_notes: None,
|
||||
provider_account_plan_type: None,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
@@ -1010,6 +1080,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
first_token_p95_ms: None,
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retried_request_count: 0,
|
||||
account_provider_kind: None,
|
||||
account_plan_type: None,
|
||||
key: name.to_owned(),
|
||||
name: name.to_owned(),
|
||||
request_count,
|
||||
|
||||
@@ -180,10 +180,6 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
|
||||
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
|
||||
estimated_usd: forecast.estimated_usd,
|
||||
estimated_usd_display: forecast_usd_display(forecast.estimated_usd),
|
||||
remaining_tokens: forecast.remaining_tokens,
|
||||
remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens),
|
||||
remaining_usd: forecast.remaining_usd,
|
||||
remaining_usd_display: forecast_usd_display(forecast.remaining_usd),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -362,10 +362,6 @@ pub struct AccountQuotaForecastView {
|
||||
pub estimated_tokens_display: String,
|
||||
pub estimated_usd: Option<f64>,
|
||||
pub estimated_usd_display: String,
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_tokens_display: String,
|
||||
pub remaining_usd: Option<f64>,
|
||||
pub remaining_usd_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -287,6 +287,8 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
|
||||
account_email: record.provider_account_email,
|
||||
account_name: record.provider_account_name,
|
||||
account_notes: record.provider_account_notes,
|
||||
account_plan_type: record.provider_account_plan_type,
|
||||
account_plan_type_display: record.provider_account_plan_type_display,
|
||||
route: record.endpoint,
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
@@ -581,6 +583,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
account_id: error.provider_account_ref,
|
||||
account_name: error.provider_account_name,
|
||||
account_email: error.provider_account_email,
|
||||
account_plan_type: error.provider_account_plan_type,
|
||||
account_plan_type_display: error.provider_account_plan_type_display,
|
||||
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
|
||||
model,
|
||||
requested_model: error.requested_model_id,
|
||||
@@ -1283,6 +1287,8 @@ pub(crate) fn diagnostics_view(
|
||||
item.name
|
||||
},
|
||||
request_count: item.request_count,
|
||||
account_plan_type: item.account_plan_type,
|
||||
account_plan_type_display: item.account_plan_type_display,
|
||||
success_count: item.success_count,
|
||||
error_count: item.error_count,
|
||||
error_rate: item.error_rate,
|
||||
|
||||
@@ -94,6 +94,8 @@ pub struct UsageListRecordView {
|
||||
pub account_email: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_notes: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
@@ -746,6 +748,8 @@ pub struct UsageInsightsOverviewView {
|
||||
pub struct DiagnosticItemView {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
@@ -788,6 +792,8 @@ pub struct OpsErrorView {
|
||||
pub account_id: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
|
||||
@@ -29,13 +29,13 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: Some(1_000_000),
|
||||
estimated_usd: None,
|
||||
remaining_tokens: Some(0),
|
||||
remaining_usd: None,
|
||||
};
|
||||
let mut monthly = forecast.clone();
|
||||
monthly.period = AccountUsagePeriod::Monthly;
|
||||
monthly.extrapolated = true;
|
||||
monthly.target_seconds = 30 * 86_400;
|
||||
monthly.estimated_tokens = Some(0);
|
||||
monthly.estimated_usd = Some(0.0);
|
||||
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
generated_at: now,
|
||||
@@ -49,7 +49,16 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(week["estimatedTokensDisplay"], "1M");
|
||||
assert!(week["estimatedUsd"].is_null());
|
||||
assert_eq!(week["estimatedUsdDisplay"], "—");
|
||||
assert_eq!(week["remainingTokensDisplay"], "0");
|
||||
for forecast in value["forecasts"].as_array().unwrap() {
|
||||
for field in [
|
||||
"remainingTokens",
|
||||
"remainingTokensDisplay",
|
||||
"remainingUsd",
|
||||
"remainingUsdDisplay",
|
||||
] {
|
||||
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
week["source"],
|
||||
serde_json::json!({
|
||||
@@ -66,6 +75,10 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert!(week.get("method").is_none());
|
||||
assert!(week.get("methodDisplay").is_none());
|
||||
assert!(value.get("generatedAtDisplay").is_none());
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsdDisplay"], "$0.00");
|
||||
assert_eq!(value["forecasts"][1]["period"], "monthly");
|
||||
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
|
||||
assert_eq!(value["forecasts"][1]["extrapolated"], true);
|
||||
|
||||
@@ -35,7 +35,7 @@ use gateway_admin::{
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
|
||||
UsageFilter, UsageListRecord, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
@@ -100,7 +100,7 @@ pub(super) struct AdminTestFixture {
|
||||
pub settings: Arc<MemorySettingsStore>,
|
||||
pub usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
pub usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
pub diagnostics: Arc<Mutex<DiagnosticsObservation>>,
|
||||
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
@@ -137,7 +137,7 @@ impl AdminTestFixture {
|
||||
let account_groups = Arc::new(MemoryAccountGroupStore::new());
|
||||
let usage_records = Arc::new(Mutex::new(Vec::new()));
|
||||
let usage_detail = Arc::new(Mutex::new(None));
|
||||
let diagnostics = Arc::new(Mutex::new(Vec::new()));
|
||||
let diagnostics = Arc::new(Mutex::new(DiagnosticsObservation::default()));
|
||||
let ops_errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let dashboard_observation = Arc::new(Mutex::new(None));
|
||||
let dashboard_summary_range = Arc::new(Mutex::new(None));
|
||||
@@ -1000,7 +1000,7 @@ struct UnusedStore {
|
||||
observations: Arc<Mutex<MemoryObservations>>,
|
||||
usage_records: Arc<Mutex<Vec<UsageListRecord>>>,
|
||||
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
diagnostics: Arc<Mutex<DiagnosticsObservation>>,
|
||||
ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
@@ -1312,7 +1312,7 @@ impl ObservabilityStore for UnusedStore {
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> AdminStoreResult<DiagnosticsObservation> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -447,6 +447,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
provider_account_ref: Some("acct_err".to_owned()),
|
||||
provider_account_name: None,
|
||||
provider_account_email: Some("err@example.invalid".to_owned()),
|
||||
provider_account_plan_type: Some("pro".to_owned()),
|
||||
provider_account_plan_type_display: Some("Pro".to_owned()),
|
||||
provider_account_authentication_kind: Some("api_key".to_owned()),
|
||||
upstream_model_id: Some("upstream-err".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
@@ -509,6 +511,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
"kind": value["data"]["items"][0]["kind"],
|
||||
"accountId": value["data"]["items"][0]["accountId"],
|
||||
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
|
||||
"accountPlanType": value["data"]["items"][0]["accountPlanType"],
|
||||
"accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"],
|
||||
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
"requestedModel": value["data"]["items"][0]["requestedModel"],
|
||||
@@ -528,6 +532,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
"kind": "model_request",
|
||||
"accountId": "acct_err",
|
||||
"accountLabel": "err@example.invalid",
|
||||
"accountPlanType": "pro",
|
||||
"accountPlanTypeDisplay": "Pro",
|
||||
"clientStatusCode": 502,
|
||||
"route": "/v1/responses",
|
||||
"requestedModel": "gpt-5.4",
|
||||
@@ -550,7 +556,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_admin::model::observability::{CostCoverage, DiagnosticObservation};
|
||||
use gateway_admin::model::observability::{
|
||||
CostCoverage, DiagnosticObservation, DiagnosticsObservation,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -558,26 +566,28 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
.diagnostics
|
||||
.lock()
|
||||
.expect("diagnostics")
|
||||
.push(DiagnosticObservation {
|
||||
*fixture.diagnostics.lock().expect("diagnostics") = DiagnosticsObservation {
|
||||
total_request_count: 4,
|
||||
items: vec![DiagnosticObservation {
|
||||
key: "acct_diag".to_owned(),
|
||||
account_provider_kind: Some("openai".to_owned()),
|
||||
account_plan_type: Some("pro".to_owned()),
|
||||
name: "diag@example.invalid".to_owned(),
|
||||
request_count: 2,
|
||||
success_count: 2,
|
||||
failure_count: 0,
|
||||
attempt_count: 2,
|
||||
attempt_count: 5,
|
||||
total_tokens: 200,
|
||||
average_latency_ms: Some(100),
|
||||
latency_p95_ms: Some(3800),
|
||||
first_token_p95_ms: Some(1200),
|
||||
non_completion_count: 0,
|
||||
retry_count: 0,
|
||||
retry_count: 3,
|
||||
retried_request_count: 1,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
});
|
||||
}],
|
||||
};
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
@@ -596,6 +606,11 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert!(value["data"]["items"][0].get("impactScore").is_none());
|
||||
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
|
||||
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["accountPlanType"], "pro");
|
||||
assert_eq!(value["data"]["items"][0]["accountPlanTypeDisplay"], "Pro");
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["items"][0]["key"],
|
||||
@@ -766,6 +781,8 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
provider_account_name: Some("Snapshot Alpha".to_owned()),
|
||||
provider_account_email: Some("alpha@example.invalid".to_owned()),
|
||||
provider_account_notes: Some("Team workspace".to_owned()),
|
||||
provider_account_plan_type: Some("supergrok_plus".to_owned()),
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: Some("oauth".to_owned()),
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
@@ -858,6 +875,14 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
|
||||
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
|
||||
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountPlanType"],
|
||||
"supergrok_plus"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["accountPlanTypeDisplay"],
|
||||
"SupergrokPlus"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["longContextBillingApplied"],
|
||||
true
|
||||
|
||||
@@ -100,6 +100,8 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_notes: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
@@ -166,6 +168,8 @@ fn error_record() -> OpsError {
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
failure_kind: "private-sentinel".to_owned(),
|
||||
|
||||
@@ -47,6 +47,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = "0.2.27"
|
||||
hyper = { version = "=1.11.1", default-features = false, features = ["server"] }
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
|
||||
wiremock = "0.6.5"
|
||||
|
||||
@@ -263,18 +263,18 @@ impl HttpClient {
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| HttpError::invalid("URL"))?;
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or_else(|| HttpError::invalid("host"))?
|
||||
.as_str();
|
||||
headers.insert(
|
||||
http::header::HOST,
|
||||
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
|
||||
);
|
||||
if url.scheme() == "http"
|
||||
&& let Some(proxy) = proxy.filter(|proxy| proxy.expose_url().starts_with("http"))
|
||||
{
|
||||
// 正向代理接收数字地址的绝对 URI;Host 保留原始域名,禁止代理再次解析目标。
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or_else(|| HttpError::invalid("host"))?
|
||||
.as_str();
|
||||
headers.insert(
|
||||
http::header::HOST,
|
||||
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
|
||||
);
|
||||
let address = addresses
|
||||
.first()
|
||||
.ok_or_else(|| HttpError::invalid("address"))?;
|
||||
|
||||
@@ -129,6 +129,7 @@ impl Downloads {
|
||||
continue;
|
||||
}
|
||||
if !(200..300).contains(&response.status) {
|
||||
tracing::warn!(status = response.status, "插件来源返回非成功状态");
|
||||
return Err(source_error());
|
||||
}
|
||||
if header(&response.headers, "content-length")
|
||||
|
||||
@@ -17,6 +17,37 @@ fn local_network() -> NetworkPolicy {
|
||||
NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_https_http2_uses_authority_without_an_extra_host_header() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::support::network::{FixedDns, Mode, ROOT, Server};
|
||||
|
||||
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
|
||||
let url = format!("https://upstream.test:{}/query", server.address.port());
|
||||
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
|
||||
.unwrap()
|
||||
.with_resolver(Arc::new(FixedDns(server.address.ip())));
|
||||
let mut response = client
|
||||
.open(
|
||||
request(url.clone()),
|
||||
None,
|
||||
&local_network(),
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body.read(64).await.unwrap().unwrap(), "secured");
|
||||
let observation = server.next().await;
|
||||
assert!(
|
||||
observation
|
||||
.head
|
||||
.starts_with(&format!("GET {url} HTTP/2.0\r\n"))
|
||||
);
|
||||
assert_eq!(observation.sni.as_deref(), Some("upstream.test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_http_does_not_follow_redirects_or_fall_back_from_proxy() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -1,13 +1,118 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::{
|
||||
model::plugins::distribution::{RemotePluginLocation, SourceAuthentication},
|
||||
model::{
|
||||
AdminErrorKind,
|
||||
plugins::distribution::{
|
||||
DownloadPurpose, RemotePluginLocation, SourceAuthentication, SourceCredential,
|
||||
SourceCredentialInfo,
|
||||
},
|
||||
},
|
||||
ports::plugins::PluginDistribution,
|
||||
};
|
||||
use gateway_host::{
|
||||
outbound::{HttpClient, NetworkPolicy},
|
||||
plugin_distribution::HttpPluginDistribution,
|
||||
};
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{method, path},
|
||||
};
|
||||
|
||||
use super::{credential, credential_with, digest, transport};
|
||||
use crate::support::network::{FixedDns, Mode, ROOT, Server};
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_http2_download_preserves_scoped_credentials_and_digest_verification() {
|
||||
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
|
||||
let origin = format!("https://upstream.test:{}", server.address.port());
|
||||
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
|
||||
.unwrap()
|
||||
.with_resolver(Arc::new(FixedDns(server.address.ip())));
|
||||
let distribution = HttpPluginDistribution::with_transport(
|
||||
&origin,
|
||||
Arc::new(client),
|
||||
NetworkPolicy::new(&["127.0.0.0/8".into()]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = distribution
|
||||
.download(
|
||||
RemotePluginLocation::Url {
|
||||
url: format!("{origin}/private/plugin.tar.gz"),
|
||||
sha256: Some(digest(b"secured")),
|
||||
},
|
||||
vec![SourceCredential {
|
||||
info: SourceCredentialInfo {
|
||||
id: "test-credential".into(),
|
||||
name: "Test".into(),
|
||||
origin,
|
||||
path_prefix: "/private".into(),
|
||||
purposes: vec![DownloadPurpose::Artifact],
|
||||
},
|
||||
authentication: SourceAuthentication::Bearer {
|
||||
token: "test-token".into(),
|
||||
},
|
||||
}],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&*result.archive, b"secured");
|
||||
assert!(
|
||||
server
|
||||
.next()
|
||||
.await
|
||||
.head
|
||||
.contains("authorization: Bearer test-token\r\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_failures_log_status_without_credentials_url_or_response_body() {
|
||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/private/plugin"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_string("private upstream response"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut output = tempfile::tempfile().unwrap();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.without_time()
|
||||
.json()
|
||||
.with_writer(output.try_clone().unwrap())
|
||||
.finish();
|
||||
let error = transport(&server)
|
||||
.download(
|
||||
RemotePluginLocation::Url {
|
||||
url: format!("{}/private/plugin", server.uri()),
|
||||
sha256: None,
|
||||
},
|
||||
vec![credential(&server, "/private")],
|
||||
None,
|
||||
)
|
||||
.with_subscriber(subscriber)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::BadGateway);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"插件来源请求失败,请检查来源与下载授权后重试"
|
||||
);
|
||||
output.seek(SeekFrom::Start(0)).unwrap();
|
||||
let mut log = String::new();
|
||||
output.read_to_string(&mut log).unwrap();
|
||||
let event: serde_json::Value = serde_json::from_str(&log).unwrap();
|
||||
assert_eq!(event["fields"]["status"], 400);
|
||||
for sensitive in ["test-token", "/private/plugin", "private upstream response"] {
|
||||
assert!(!log.contains(sensitive));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn url_preview_computes_digest_and_confirmation_rejects_replaced_content() {
|
||||
|
||||
@@ -36,6 +36,7 @@ pub struct Observation {
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Mode {
|
||||
Origin,
|
||||
Http2Origin,
|
||||
HttpProxy,
|
||||
Socks,
|
||||
}
|
||||
@@ -57,7 +58,7 @@ impl Server {
|
||||
let listener = TcpListener::bind(bind).await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (sender, received) = mpsc::unbounded_channel();
|
||||
let acceptor = tls.then(tls_acceptor);
|
||||
let acceptor = tls.then(|| tls_acceptor(matches!(mode, Mode::Http2Origin)));
|
||||
let task = tokio::spawn(async move {
|
||||
let mut connections = JoinSet::new();
|
||||
loop {
|
||||
@@ -104,6 +105,9 @@ async fn serve(
|
||||
mode: Mode,
|
||||
observations: mpsc::UnboundedSender<Observation>,
|
||||
) -> io::Result<()> {
|
||||
if matches!(mode, Mode::Http2Origin) {
|
||||
return serve_http2(stream, sni, observations).await;
|
||||
}
|
||||
if matches!(mode, Mode::Socks) {
|
||||
return socks(stream, observations).await;
|
||||
}
|
||||
@@ -163,6 +167,50 @@ async fn serve(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_http2(
|
||||
stream: Box<dyn Socket>,
|
||||
sni: Option<String>,
|
||||
observations: mpsc::UnboundedSender<Observation>,
|
||||
) -> io::Result<()> {
|
||||
let service =
|
||||
hyper::service::service_fn(move |request: http::Request<hyper::body::Incoming>| {
|
||||
// 模拟 nginx 1.24:URI 已携带 :authority,额外的 Host 仍按重复头拒绝。
|
||||
let status = if request.headers().contains_key(http::header::HOST) {
|
||||
400
|
||||
} else {
|
||||
200
|
||||
};
|
||||
let mut head = format!(
|
||||
"{} {} {:?}\r\n",
|
||||
request.method(),
|
||||
request.uri(),
|
||||
request.version()
|
||||
);
|
||||
for (name, value) in request.headers() {
|
||||
head.push_str(&format!("{name}: {}\r\n", value.to_str().unwrap()));
|
||||
}
|
||||
observations
|
||||
.send(Observation {
|
||||
head,
|
||||
sni: sni.clone(),
|
||||
target: None,
|
||||
authentication: None,
|
||||
})
|
||||
.unwrap();
|
||||
let response = http::Response::builder()
|
||||
.status(status)
|
||||
.body(http_body_util::Full::new(bytes::Bytes::from_static(
|
||||
b"secured",
|
||||
)))
|
||||
.unwrap();
|
||||
async { Ok::<_, std::convert::Infallible>(response) }
|
||||
});
|
||||
hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
|
||||
.serve_connection(hyper_util::rt::TokioIo::new(stream), service)
|
||||
.await
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
async fn read_head(stream: &mut Box<dyn Socket>) -> io::Result<String> {
|
||||
let mut bytes = Vec::new();
|
||||
while !bytes.ends_with(b"\r\n\r\n") {
|
||||
@@ -221,12 +269,12 @@ async fn socks(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tls_acceptor() -> TlsAcceptor {
|
||||
fn tls_acceptor(http2: bool) -> TlsAcceptor {
|
||||
use rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||
};
|
||||
let config = ServerConfig::builder_with_provider(Arc::new(
|
||||
let mut config = ServerConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::aws_lc_rs::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()
|
||||
@@ -241,5 +289,8 @@ fn tls_acceptor() -> TlsAcceptor {
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
if http2 {
|
||||
config.alpn_protocols = vec![b"h2".to_vec()];
|
||||
}
|
||||
TlsAcceptor::from(Arc::new(config))
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() {
|
||||
"client_key_id":"key_budget", "daily_limit_usd":"10", "weekly_limit_usd":"20",
|
||||
"daily_used_usd":"3", "weekly_used_usd":"0",
|
||||
"daily_resets_at_ms":before.budget.daily_resets_at.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis()),
|
||||
"weekly_resets_at_ms":before.budget.weekly_resets_at.map(|time| chrono::DateTime::<chrono::Utc>::from(time).timestamp_millis()),
|
||||
"weekly_resets_at_ms":null,
|
||||
});
|
||||
assert_eq!(results[6], expected);
|
||||
assert_eq!(results[7], json!({"client_key_id":"key_budget"}));
|
||||
@@ -126,10 +126,7 @@ async fn plugin_process_manages_native_budget_through_client_key_service() {
|
||||
1
|
||||
);
|
||||
assert_eq!(after.budget.daily_resets_at, before.budget.daily_resets_at);
|
||||
assert_eq!(
|
||||
after.budget.weekly_resets_at,
|
||||
before.budget.weekly_resets_at
|
||||
);
|
||||
assert_eq!(after.budget.weekly_resets_at, None);
|
||||
assert_eq!(audits.len(), 1);
|
||||
assert!(audits[0].starts_with(&format!("plugin:{}:scope:", view.target.instance_id)));
|
||||
}
|
||||
|
||||
@@ -644,8 +644,24 @@ impl Environment {
|
||||
}
|
||||
|
||||
async fn create_with_store_mode(command_line: bool) -> Option<Self> {
|
||||
let database = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL").ok()?;
|
||||
let redis = std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL");
|
||||
// 插件专用服务优先;CI 的标准测试服务同样通过随机 schema 隔离数据库。
|
||||
let (database, redis) = if let Ok(database) = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL")
|
||||
{
|
||||
(
|
||||
database,
|
||||
std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL"),
|
||||
)
|
||||
} else {
|
||||
let database = std::env::var("CPR_TEST_DATABASE_URL").ok();
|
||||
assert!(
|
||||
database.is_some() || std::env::var_os("CI").is_none(),
|
||||
"CI requires plugin or standard test services"
|
||||
);
|
||||
(
|
||||
database?,
|
||||
std::env::var("CPR_TEST_REDIS_URL").expect("isolated test Redis URL"),
|
||||
)
|
||||
};
|
||||
let schema = format!("cpr_plugin_{}", uuid::Uuid::new_v4().simple());
|
||||
let admin = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
|
||||
@@ -178,7 +178,7 @@ Key 明文仍通过宿主管理面查看,插件模型调用使用返回的 Key
|
||||
相同值再次赋值不产生新 revision 或审计;并发更新按事务顺序生效,同一字段由后提交的值覆盖,接口不提供调用去重或比较交换。
|
||||
将非零上限降低到已用金额及以下会拒绝后续准入,提高限额可恢复准入,在途请求仍按原生规则完成和结算
|
||||
|
||||
重置只清零所选周期,保留额度上限、密钥、窗口到期时间和历史费用事件;未使用的 Key 不因此开启窗口。
|
||||
重置清零所选周期并清除到期时间,保留额度上限、密钥和历史费用事件;所选窗口下次使用时按原生规则重新开启。
|
||||
费用按完成时间归属,重置前完成但延迟落盘的费用不会重新扣入已重置周期,重置后完成的在途请求仍正常扣额。
|
||||
实例版本复验、清零和审计在同一事务提交,不推进配置 revision。每次调用均执行新重置,可能清掉两次调用之间的新消费
|
||||
|
||||
@@ -186,6 +186,12 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限
|
||||
不存在的 Key 返回 `rejected`;写入事务复验发现实例停用或版本变化时返回 `conflict`,
|
||||
非法输入返回 `invalid_input`。账号关联、预算分配和重置触发由插件决定,宿主不自动串联上述接口
|
||||
|
||||
插件的外部周期、截止时间和同步进度保存在自身 `host.state.*` 中,并通过自己的管理页面展示;
|
||||
这些数据不会改写宿主日/周窗口及其显示。插件可在 `request` 中间件中按自己的策略提前拒绝请求,
|
||||
在确认新周期后调用预算重置或上限修改接口;调用仍遵循上述原生语义,宿主窗口继续按自身日期规则滚动。
|
||||
私有状态写入与预算操作不是一个事务,不能把私有状态标记当作预算操作已提交的证明;结果未知时须按上述合同处理。
|
||||
停用插件会停止其后续拦截与同步,已执行的额度修改或重置不会自动撤销
|
||||
|
||||
### Key、模型与模型调用
|
||||
|
||||
模型调用方法:
|
||||
|
||||
@@ -53,7 +53,7 @@ impl HostClient {
|
||||
serde_json::from_value(reply.result).map_err(|_| invalid())
|
||||
}
|
||||
|
||||
/// 清零指定周期,保留限额和到期时间;结果未知时不能盲目重试。
|
||||
/// 清零并关闭指定周期,保留限额,下次使用时重新开启;结果未知时不能盲目重试。
|
||||
///
|
||||
/// # Errors
|
||||
/// 实例过期、Key 不存在、宿主写入失败时返回错误。
|
||||
|
||||
@@ -78,13 +78,15 @@ async fn reset_client_key_budget_in_transaction(
|
||||
ClientKeyBudgetPeriod::Weekly | ClientKeyBudgetPeriod::All
|
||||
);
|
||||
let reset_at = Utc::now();
|
||||
// 推进计费起点,避免重置前完成、稍后落盘的费用重新扣额;未使用的 Key 不开启窗口。
|
||||
// 起止时间收拢到重置边界,窗口保持未开启,同时排除重置前完成的迟到费用。
|
||||
sqlx::query(
|
||||
"update client_key_budget_windows set
|
||||
daily_used_usd = case when $2 then 0 else daily_used_usd end,
|
||||
daily_start = case when $2 and daily_end > $4 then $4 else daily_start end,
|
||||
daily_start = case when $2 then $4 else daily_start end,
|
||||
daily_end = case when $2 then $4 else daily_end end,
|
||||
weekly_used_usd = case when $3 then 0 else weekly_used_usd end,
|
||||
weekly_start = case when $3 and weekly_end > $4 then $4 else weekly_start end
|
||||
weekly_start = case when $3 then $4 else weekly_start end,
|
||||
weekly_end = case when $3 then $4 else weekly_end end
|
||||
where client_api_key_id = $1",
|
||||
)
|
||||
.bind(command.id.as_str())
|
||||
@@ -96,10 +98,18 @@ async fn reset_client_key_budget_in_transaction(
|
||||
.map_err(|_| postgres_unavailable("reset client budget"))?;
|
||||
let mut fields = Vec::new();
|
||||
if daily {
|
||||
fields.extend(["daily_used_usd".to_owned(), "daily_start".to_owned()]);
|
||||
fields.extend([
|
||||
"daily_used_usd".to_owned(),
|
||||
"daily_start".to_owned(),
|
||||
"daily_end".to_owned(),
|
||||
]);
|
||||
}
|
||||
if weekly {
|
||||
fields.extend(["weekly_used_usd".to_owned(), "weekly_start".to_owned()]);
|
||||
fields.extend([
|
||||
"weekly_used_usd".to_owned(),
|
||||
"weekly_start".to_owned(),
|
||||
"weekly_end".to_owned(),
|
||||
]);
|
||||
}
|
||||
super::append_admin_audit_event_in_transaction(
|
||||
tx,
|
||||
@@ -174,7 +184,7 @@ impl PgClientBudgetStore {
|
||||
.map_err(|_| unavailable())?,
|
||||
};
|
||||
let now = Utc::now();
|
||||
advance_windows(&mut tx, key_id.as_str(), now)
|
||||
advance_windows(&mut tx, key_id.as_str(), now, now)
|
||||
.await
|
||||
.map_err(|_| unavailable())?;
|
||||
if limits.is_limited() {
|
||||
@@ -243,7 +253,7 @@ async fn settle_in_transaction(
|
||||
key: &str,
|
||||
charge: &ClientBudgetCharge,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
advance_windows(tx, key, Utc::now()).await?;
|
||||
let completed_at = DateTime::<Utc>::from(charge.completed_at);
|
||||
// 仅在请求结束时写入费用;请求 ID 冲突时不重复累计。
|
||||
let changed = sqlx::query(
|
||||
"insert into client_key_charge_events (request_id, client_api_key_id, amount_usd, completed_at)
|
||||
@@ -253,16 +263,17 @@ async fn settle_in_transaction(
|
||||
.bind(charge.request_id.as_str())
|
||||
.bind(key)
|
||||
.bind(charge.amount_usd.canonical())
|
||||
.bind(DateTime::<Utc>::from(charge.completed_at))
|
||||
.bind(completed_at)
|
||||
.execute(&mut **tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if changed == 1 {
|
||||
advance_windows(tx, key, Utc::now(), completed_at).await?;
|
||||
sqlx::query("update client_key_budget_windows set
|
||||
daily_used_usd = daily_used_usd + case when $3 >= daily_start and $3 < daily_end then $2::text::numeric else 0 end,
|
||||
weekly_used_usd = weekly_used_usd + case when $3 >= weekly_start and $3 < weekly_end then $2::text::numeric else 0 end
|
||||
where client_api_key_id = $1")
|
||||
.bind(key).bind(charge.amount_usd.canonical()).bind(DateTime::<Utc>::from(charge.completed_at))
|
||||
.bind(key).bind(charge.amount_usd.canonical()).bind(completed_at)
|
||||
.execute(&mut **tx).await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -291,19 +302,21 @@ async fn advance_windows(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
key: &str,
|
||||
now: DateTime<Utc>,
|
||||
used_at: DateTime<Utc>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
// 到期日仍按北京时间零点计算,计费起点不能退回重置前;迟到费用不启动新窗口。
|
||||
sqlx::query("insert into client_key_budget_windows
|
||||
(client_api_key_id, daily_start, daily_end, weekly_start, weekly_end)
|
||||
select $1, day, day + interval '24 hours', day, day + interval '168 hours'
|
||||
from (select date_trunc('day', $2::timestamptz at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai' as day) d
|
||||
on conflict (client_api_key_id) do update set
|
||||
daily_start = case when client_key_budget_windows.daily_end <= $2 then excluded.daily_start else client_key_budget_windows.daily_start end,
|
||||
daily_end = case when client_key_budget_windows.daily_end <= $2 then excluded.daily_end else client_key_budget_windows.daily_end end,
|
||||
daily_used_usd = case when client_key_budget_windows.daily_end <= $2 then 0 else client_key_budget_windows.daily_used_usd end,
|
||||
weekly_start = case when client_key_budget_windows.weekly_end <= $2 then excluded.weekly_start else client_key_budget_windows.weekly_start end,
|
||||
weekly_end = case when client_key_budget_windows.weekly_end <= $2 then excluded.weekly_end else client_key_budget_windows.weekly_end end,
|
||||
weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 then 0 else client_key_budget_windows.weekly_used_usd end")
|
||||
.bind(key).bind(now).execute(&mut **tx).await?;
|
||||
daily_start = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then greatest(excluded.daily_start, client_key_budget_windows.daily_start) else client_key_budget_windows.daily_start end,
|
||||
daily_end = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then excluded.daily_end else client_key_budget_windows.daily_end end,
|
||||
daily_used_usd = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then 0 else client_key_budget_windows.daily_used_usd end,
|
||||
weekly_start = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then greatest(excluded.weekly_start, client_key_budget_windows.weekly_start) else client_key_budget_windows.weekly_start end,
|
||||
weekly_end = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then excluded.weekly_end else client_key_budget_windows.weekly_end end,
|
||||
weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then 0 else client_key_budget_windows.weekly_used_usd end")
|
||||
.bind(key).bind(now).bind(used_at).execute(&mut **tx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ impl ObservabilityRepository for PgObservabilityRepository {
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> StoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> StoreResult<DiagnosticsObservation> {
|
||||
self.query_budget
|
||||
.run(
|
||||
"load usage diagnostics",
|
||||
@@ -441,18 +441,17 @@ impl AdminObservabilityStore for PgAdminObservabilityStore {
|
||||
range: admin_observability::TimeRange,
|
||||
filter: admin_observability::UsageFilter,
|
||||
dimension: admin_observability::DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<admin_observability::DiagnosticObservation>> {
|
||||
self.repository
|
||||
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
|
||||
let observation = self
|
||||
.repository
|
||||
.usage_diagnostics(
|
||||
store_range(range)?,
|
||||
store_usage_filter(filter),
|
||||
store_diagnostic_dimension(dimension),
|
||||
)
|
||||
.await
|
||||
.map_err(observability_error)?
|
||||
.into_iter()
|
||||
.map(admin_diagnostic_observation)
|
||||
.collect()
|
||||
.map_err(observability_error)?;
|
||||
admin_diagnostics_observation(observation)
|
||||
}
|
||||
|
||||
async fn list_ops_errors(
|
||||
|
||||
@@ -459,6 +459,8 @@ pub(crate) fn admin_usage_list_record(
|
||||
provider_account_name: record.provider_account_name,
|
||||
provider_account_email: record.provider_account_email,
|
||||
provider_account_notes: record.provider_account_notes,
|
||||
provider_account_plan_type: record.provider_account_plan_type,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: record.provider_account_authentication_kind,
|
||||
upstream_model_id: record.upstream_model_id,
|
||||
upstream_transport: record.upstream_transport,
|
||||
@@ -688,12 +690,27 @@ pub(crate) fn admin_provider_observation(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn admin_diagnostic_observation(
|
||||
pub(crate) fn admin_diagnostics_observation(
|
||||
observation: DiagnosticsObservation,
|
||||
) -> AdminStoreResult<admin_observability::DiagnosticsObservation> {
|
||||
Ok(admin_observability::DiagnosticsObservation {
|
||||
total_request_count: observation.total_request_count,
|
||||
items: observation
|
||||
.items
|
||||
.into_iter()
|
||||
.map(admin_diagnostic_observation)
|
||||
.collect::<AdminStoreResult<_>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_diagnostic_observation(
|
||||
observation: DiagnosticObservation,
|
||||
) -> AdminStoreResult<admin_observability::DiagnosticObservation> {
|
||||
Ok(admin_observability::DiagnosticObservation {
|
||||
key: observation.key,
|
||||
name: observation.name,
|
||||
account_provider_kind: observation.account_provider_kind,
|
||||
account_plan_type: observation.account_plan_type,
|
||||
request_count: observation.request_count,
|
||||
success_count: observation.success_count,
|
||||
failure_count: observation.failure_count,
|
||||
@@ -704,6 +721,7 @@ pub(crate) fn admin_diagnostic_observation(
|
||||
first_token_p95_ms: observation.first_token_p95_ms,
|
||||
non_completion_count: observation.non_completion_count,
|
||||
retry_count: observation.retry_count,
|
||||
retried_request_count: observation.retried_request_count,
|
||||
cost_coverage: admin_cost_coverage(observation.cost_coverage),
|
||||
costs: admin_currency_costs(observation.costs)?,
|
||||
})
|
||||
@@ -739,6 +757,8 @@ pub(crate) fn admin_ops_error(error: OpsErrorRecord) -> admin_observability::Ops
|
||||
provider_account_ref: error.provider_account_ref,
|
||||
provider_account_name: error.provider_account_name,
|
||||
provider_account_email: error.provider_account_email,
|
||||
provider_account_plan_type: error.provider_account_plan_type,
|
||||
provider_account_plan_type_display: None,
|
||||
provider_account_authentication_kind: error.provider_account_authentication_kind,
|
||||
upstream_model_id: error.upstream_model_id,
|
||||
upstream_transport: error.upstream_transport,
|
||||
@@ -791,6 +811,7 @@ pub(crate) fn usage_list_record_from_row(
|
||||
provider_account_name: get(row, "provider_account_name")?,
|
||||
provider_account_email: get(row, "provider_account_email")?,
|
||||
provider_account_notes: get(row, "provider_account_notes")?,
|
||||
provider_account_plan_type: get(row, "provider_account_plan_type")?,
|
||||
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
@@ -936,6 +957,7 @@ pub(crate) fn ops_error_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<Ops
|
||||
provider_account_ref: get(row, "provider_account_ref")?,
|
||||
provider_account_name: get(row, "provider_account_name")?,
|
||||
provider_account_email: get(row, "provider_account_email")?,
|
||||
provider_account_plan_type: get(row, "provider_account_plan_type")?,
|
||||
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
|
||||
upstream_model_id: get(row, "upstream_model_id")?,
|
||||
upstream_transport: get(row, "upstream_transport")?,
|
||||
|
||||
@@ -491,6 +491,7 @@ pub struct UsageListRecord {
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_notes: Option<String>,
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -675,10 +676,18 @@ pub struct UsageOverview {
|
||||
pub providers: Vec<ProviderObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -689,6 +698,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCostTotal>,
|
||||
}
|
||||
@@ -712,6 +722,7 @@ pub struct OpsErrorRecord {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -793,6 +804,6 @@ pub trait ObservabilityRepository: Send + Sync {
|
||||
range: ObservabilityRange,
|
||||
filter: UsageRecordFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> StoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> StoreResult<DiagnosticsObservation>;
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> StoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
|
||||
mr.endpoint, mr.provider_kind, mr.provider_account_ref,
|
||||
mr.provider_account_name_snapshot as provider_account_name,
|
||||
mr.provider_account_email_snapshot as provider_account_email,
|
||||
account.plan_type as provider_account_plan_type,
|
||||
mr.provider_account_authentication_kind_snapshot
|
||||
as provider_account_authentication_kind,
|
||||
mr.upstream_model_id, mr.upstream_transport,
|
||||
@@ -33,6 +34,7 @@ const REQUEST_ERROR_SELECT: &str = "select 'model_request'::text as source,
|
||||
'model_request:' || mr.id as stable_sort_id
|
||||
from model_requests mr
|
||||
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
|
||||
left join provider_accounts account on account.id = mr.provider_account_ref
|
||||
where true";
|
||||
|
||||
const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
|
||||
@@ -42,6 +44,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
|
||||
mr.endpoint, oe.provider_kind, oe.provider_account_ref,
|
||||
oe.provider_account_name_snapshot as provider_account_name,
|
||||
oe.provider_account_email_snapshot as provider_account_email,
|
||||
account.plan_type as provider_account_plan_type,
|
||||
oe.provider_account_authentication_kind_snapshot
|
||||
as provider_account_authentication_kind,
|
||||
oe.upstream_model_id, null::text as upstream_transport, oe.failure_kind,
|
||||
@@ -66,6 +69,7 @@ const OPS_EVENT_SELECT: &str = "select 'ops_event'::text as source,
|
||||
from ops_events oe
|
||||
left join model_requests mr on mr.id = oe.model_request_id
|
||||
left join client_api_keys client_key on client_key.id = mr.client_api_key_ref
|
||||
left join provider_accounts account on account.id = oe.provider_account_ref
|
||||
where true";
|
||||
|
||||
pub(crate) async fn list_ops_errors(
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(crate) const USAGE_LIST_RECORD_SELECT: &str =
|
||||
mr.provider_account_name_snapshot as provider_account_name,
|
||||
mr.provider_account_email_snapshot as provider_account_email,
|
||||
account.notes as provider_account_notes,
|
||||
account.plan_type as provider_account_plan_type,
|
||||
mr.provider_account_authentication_kind_snapshot
|
||||
as provider_account_authentication_kind,
|
||||
mr.upstream_model_id, mr.upstream_transport, mr.upstream_response_model, mr.service_tier,
|
||||
@@ -384,7 +385,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
range: ObservabilityRange,
|
||||
filter: &UsageRecordFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> StoreResult<Vec<DiagnosticObservation>> {
|
||||
) -> StoreResult<DiagnosticsObservation> {
|
||||
filter.validate()?;
|
||||
let dimension_sql = diagnostic_dimension_sql(dimension);
|
||||
let completed_usage = completed_usage_fact_predicate("mr");
|
||||
@@ -424,6 +425,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
count(*) filter (where outcome in ('cancelled', 'incomplete'))::bigint
|
||||
as non_completion_count,
|
||||
coalesce(sum(greatest(attempt_count - 1, 0)), 0)::bigint as retry_count,
|
||||
count(*) filter (where attempt_count > 1)::bigint as retried_request_count,
|
||||
count(*) filter (
|
||||
where is_completed_usage and cost_source = 'provider_reported'
|
||||
)::bigint
|
||||
@@ -443,6 +445,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
group by dimension_name, grouping sets ((), (cost_currency))
|
||||
), selected_dimensions as (
|
||||
select dimension_name,
|
||||
sum(request_count) over ()::bigint as total_request_count,
|
||||
row_number() over (order by request_count desc, dimension_name)
|
||||
as sort_position
|
||||
from aggregated
|
||||
@@ -452,16 +455,25 @@ pub(crate) async fn usage_diagnostics(
|
||||
statement.push_bind(DIAGNOSTIC_LIMIT);
|
||||
statement.push(
|
||||
")
|
||||
select aggregated.*
|
||||
select aggregated.*, selected.total_request_count,
|
||||
account.provider_kind as account_provider_kind, account.plan_type as account_plan_type
|
||||
from aggregated
|
||||
join selected_dimensions selected using (dimension_name)
|
||||
order by selected.sort_position, currency_grouping desc, cost_currency nulls last",
|
||||
left join provider_accounts account on account.id = aggregated.dimension_name and ",
|
||||
);
|
||||
statement.push_bind(dimension == DiagnosticDimension::Account);
|
||||
statement
|
||||
.push(" order by selected.sort_position, currency_grouping desc, cost_currency nulls last");
|
||||
let rows = statement
|
||||
.build()
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("load usage diagnostics"))?;
|
||||
let total_request_count = rows
|
||||
.first()
|
||||
.map(|row| unsigned(row, "total_request_count"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let mut observations = Vec::with_capacity(DIAGNOSTIC_LIMIT as usize);
|
||||
let mut costs = HashMap::<String, Vec<CurrencyCostTotal>>::new();
|
||||
for row in &rows {
|
||||
@@ -469,6 +481,8 @@ pub(crate) async fn usage_diagnostics(
|
||||
1 => observations.push(DiagnosticObservation {
|
||||
key: get(row, "dimension_name")?,
|
||||
name: get(row, "dimension_name")?,
|
||||
account_provider_kind: get(row, "account_provider_kind")?,
|
||||
account_plan_type: get(row, "account_plan_type")?,
|
||||
request_count: unsigned(row, "request_count")?,
|
||||
success_count: unsigned(row, "success_count")?,
|
||||
failure_count: unsigned(row, "failure_count")?,
|
||||
@@ -479,6 +493,7 @@ pub(crate) async fn usage_diagnostics(
|
||||
first_token_p95_ms: optional_unsigned(row, "first_token_p95_ms")?,
|
||||
non_completion_count: unsigned(row, "non_completion_count")?,
|
||||
retry_count: unsigned(row, "retry_count")?,
|
||||
retried_request_count: unsigned(row, "retried_request_count")?,
|
||||
cost_coverage: coverage_from_row(row)?,
|
||||
costs: Vec::new(),
|
||||
}),
|
||||
@@ -523,7 +538,10 @@ pub(crate) async fn usage_diagnostics(
|
||||
}
|
||||
observation.costs = costs.remove(&observation.key).unwrap_or_default();
|
||||
}
|
||||
Ok(observations)
|
||||
Ok(DiagnosticsObservation {
|
||||
total_request_count,
|
||||
items: observations,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn diagnostic_account_display_names(
|
||||
|
||||
@@ -75,7 +75,7 @@ fn context() -> MutationContext {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_and_expiry() {
|
||||
async fn manual_reset_clears_selected_windows_and_preserves_policy_and_history() {
|
||||
let Some(database) = TestDatabase::create("budget_manual_reset").await else {
|
||||
return;
|
||||
};
|
||||
@@ -108,14 +108,25 @@ async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_a
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// 同一费用重试仍由事件 ID 去重,不因清零而被再次累计。
|
||||
store.settle(billed).await.unwrap();
|
||||
let after = status(&database, key).await;
|
||||
assert_eq!(after.daily_used_usd.canonical(), daily);
|
||||
assert_eq!(after.weekly_used_usd.canonical(), weekly);
|
||||
assert_eq!(after.limits, before.limits);
|
||||
assert_eq!(after.daily_resets_at, before.daily_resets_at);
|
||||
assert_eq!(after.weekly_resets_at, before.weekly_resets_at);
|
||||
assert_eq!(
|
||||
after.daily_resets_at,
|
||||
(period == ClientKeyBudgetPeriod::Weekly)
|
||||
.then_some(before.daily_resets_at)
|
||||
.flatten()
|
||||
);
|
||||
assert_eq!(
|
||||
after.weekly_resets_at,
|
||||
(period == ClientKeyBudgetPeriod::Daily)
|
||||
.then_some(before.weekly_resets_at)
|
||||
.flatten()
|
||||
);
|
||||
// 同一费用重试既不重复扣额,也不能重新开启已重置的窗口。
|
||||
store.settle(billed).await.unwrap();
|
||||
assert_eq!(status(&database, key).await, after);
|
||||
assert_eq!(
|
||||
store.admit(key_id(key)).await.is_ok(),
|
||||
period == ClientKeyBudgetPeriod::All
|
||||
@@ -130,6 +141,118 @@ async fn manual_reset_clears_only_selected_budget_and_preserves_policy_history_a
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_reset_restarts_selected_windows_only_after_new_usage() {
|
||||
let Some(database) = TestDatabase::create("budget_reset_restart").await else {
|
||||
return;
|
||||
};
|
||||
let store = PgClientBudgetStore::new(database.pool.clone());
|
||||
let admin = PgAdminClientKeyStore::new(database.pool.clone());
|
||||
for (key, period) in [
|
||||
("daily", ClientKeyBudgetPeriod::Daily),
|
||||
("weekly", ClientKeyBudgetPeriod::Weekly),
|
||||
("all", ClientKeyBudgetPeriod::All),
|
||||
] {
|
||||
seed(&database, key, "10", "20").await;
|
||||
store.admit(key_id(key)).await.unwrap();
|
||||
store.settle(charge(key, key, "1")).await.unwrap();
|
||||
sqlx::query("update client_key_budget_windows set weekly_start = weekly_start - interval '3 days', weekly_end = weekly_end - interval '3 days' where client_api_key_id = $1")
|
||||
.bind(key).execute(&database.pool).await.unwrap();
|
||||
let before = status(&database, key).await;
|
||||
let delayed = charge(key, &format!("{key}-delayed"), "0.5");
|
||||
let old = charge(key, &format!("{key}-old"), "0.7");
|
||||
admin
|
||||
.reset_client_key_budget(
|
||||
ResetClientKeyBudget {
|
||||
id: key_id(key),
|
||||
period,
|
||||
},
|
||||
ClientKeyBudgetMutationOrigin::Admin,
|
||||
&context(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let reset = status(&database, key).await;
|
||||
// 重置前完成的迟到费用保留历史,但不能开启窗口或重新扣入所选周期。
|
||||
store.settle(delayed).await.unwrap();
|
||||
let delayed = status(&database, key).await;
|
||||
assert_eq!(delayed.daily_resets_at, reset.daily_resets_at);
|
||||
assert_eq!(delayed.weekly_resets_at, reset.weekly_resets_at);
|
||||
assert_eq!(
|
||||
delayed.daily_used_usd.canonical(),
|
||||
if period == ClientKeyBudgetPeriod::Weekly {
|
||||
"1.5"
|
||||
} else {
|
||||
"0"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.weekly_used_usd.canonical(),
|
||||
if period == ClientKeyBudgetPeriod::Daily {
|
||||
"1.5"
|
||||
} else {
|
||||
"0"
|
||||
}
|
||||
);
|
||||
|
||||
store.admit(key_id(key)).await.unwrap();
|
||||
let restarted = status(&database, key).await;
|
||||
assert_eq!(restarted.daily_resets_at, before.daily_resets_at);
|
||||
if period == ClientKeyBudgetPeriod::Daily {
|
||||
assert_eq!(restarted.weekly_resets_at, before.weekly_resets_at);
|
||||
} else {
|
||||
assert_eq!(
|
||||
restarted.weekly_resets_at.unwrap(),
|
||||
restarted.daily_resets_at.unwrap() + std::time::Duration::from_secs(6 * 86400)
|
||||
);
|
||||
assert_ne!(restarted.weekly_resets_at, before.weekly_resets_at);
|
||||
}
|
||||
// 开启窗口后仍保留重置边界,另一笔重置前的迟到费用也不能重新扣额。
|
||||
store.settle(old).await.unwrap();
|
||||
let after_old = status(&database, key).await;
|
||||
assert_eq!(after_old.daily_resets_at, restarted.daily_resets_at);
|
||||
assert_eq!(after_old.weekly_resets_at, restarted.weekly_resets_at);
|
||||
assert_eq!(
|
||||
after_old.daily_used_usd.canonical(),
|
||||
if period == ClientKeyBudgetPeriod::Weekly {
|
||||
"2.2"
|
||||
} else {
|
||||
"0"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
after_old.weekly_used_usd.canonical(),
|
||||
if period == ClientKeyBudgetPeriod::Daily {
|
||||
"2.2"
|
||||
} else {
|
||||
"0"
|
||||
}
|
||||
);
|
||||
store
|
||||
.settle(charge(key, &format!("{key}-new"), "0.25"))
|
||||
.await
|
||||
.unwrap();
|
||||
let used = status(&database, key).await;
|
||||
assert_eq!(
|
||||
used.daily_used_usd.canonical(),
|
||||
if period == ClientKeyBudgetPeriod::Weekly {
|
||||
"2.45"
|
||||
} else {
|
||||
"0.25"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
used.weekly_used_usd.canonical(),
|
||||
if period == ClientKeyBudgetPeriod::Daily {
|
||||
"2.45"
|
||||
} else {
|
||||
"0.25"
|
||||
}
|
||||
);
|
||||
}
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_reset_excludes_old_completions_but_counts_inflight_requests_after_reset() {
|
||||
let Some(database) = TestDatabase::create("budget_reset_late").await else {
|
||||
@@ -648,7 +771,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() {
|
||||
assert_eq!(after.weekly_used_usd.canonical(), "0");
|
||||
assert_eq!(after.limits, before.limits);
|
||||
assert_eq!(after.daily_resets_at, before.daily_resets_at);
|
||||
assert_eq!(after.weekly_resets_at, before.weekly_resets_at);
|
||||
assert!(after.weekly_resets_at.is_none());
|
||||
|
||||
budgets.settle(charge("key", "after", "2")).await.unwrap();
|
||||
assert_eq!(
|
||||
@@ -666,6 +789,7 @@ async fn plugin_resets_share_the_native_ledger_and_each_call_resets_again() {
|
||||
let after = status(&database, "key").await;
|
||||
assert_eq!(after.daily_used_usd.canonical(), "5");
|
||||
assert_eq!(after.weekly_used_usd.canonical(), "0");
|
||||
assert!(after.weekly_resets_at.is_none());
|
||||
let (audits, charges, current_revision): (i64, i64, i64) = sqlx::query_as(
|
||||
"select (select count(*) from admin_audit_events where action='reset_budget' and config_revision is null),
|
||||
(select count(*) from client_key_charge_events),
|
||||
|
||||
@@ -160,7 +160,7 @@ async fn usage_page_should_always_return_total() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
async fn usage_list_and_diagnostics_should_resolve_current_account_metadata_by_id() {
|
||||
let Some(database) = TestDatabase::create("usage_account_notes").await else {
|
||||
return;
|
||||
};
|
||||
@@ -197,12 +197,20 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
.expect("admin observability range");
|
||||
let store = admin_observability_store(&database.pool);
|
||||
|
||||
for notes in [None, Some("个人主号"), Some("个人备用号"), None] {
|
||||
sqlx::query("update provider_accounts set notes = $1 where id = 'acct_observe'")
|
||||
.bind(notes)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("update current account notes");
|
||||
for (notes, plan) in [
|
||||
(None, Some("pro")),
|
||||
(Some("个人主号"), Some("plus")),
|
||||
(Some("个人备用号"), Some("pro")),
|
||||
(None, None),
|
||||
] {
|
||||
sqlx::query(
|
||||
"update provider_accounts set notes = $1, plan_type = $2 where id = 'acct_observe'",
|
||||
)
|
||||
.bind(notes)
|
||||
.bind(plan)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("update current account notes");
|
||||
let page = store
|
||||
.list_usage_records(admin_observability::UsageQuery {
|
||||
range,
|
||||
@@ -230,6 +238,36 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
);
|
||||
assert_eq!(personal.provider_account_email, team.provider_account_email);
|
||||
assert_eq!(personal.provider_account_notes.as_deref(), notes);
|
||||
assert_eq!(personal.provider_account_plan_type.as_deref(), plan);
|
||||
assert_eq!(team.provider_account_plan_type.as_deref(), Some("team"));
|
||||
let diagnostics = store
|
||||
.usage_diagnostics(
|
||||
range,
|
||||
admin_observability::UsageFilter::default(),
|
||||
admin_observability::DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account plans");
|
||||
assert_eq!(
|
||||
diagnostics
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.key == "acct_observe")
|
||||
.expect("personal diagnostics")
|
||||
.account_plan_type
|
||||
.as_deref(),
|
||||
plan
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.key == "acct_team")
|
||||
.expect("team diagnostics")
|
||||
.account_plan_type
|
||||
.as_deref(),
|
||||
Some("team")
|
||||
);
|
||||
assert_eq!(team.provider_account_notes.as_deref(), Some("团队工作区"));
|
||||
}
|
||||
|
||||
@@ -274,6 +312,19 @@ async fn usage_list_should_resolve_current_notes_by_account_id() {
|
||||
Some("account@example.invalid")
|
||||
);
|
||||
assert_eq!(page.items[0].provider_account_notes, None);
|
||||
assert_eq!(page.items[0].provider_account_plan_type, None);
|
||||
let diagnostics = store
|
||||
.usage_diagnostics(
|
||||
range,
|
||||
admin_observability::UsageFilter {
|
||||
provider_account_ref: Some("acct_team".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
admin_observability::DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("deleted account diagnostics");
|
||||
assert_eq!(diagnostics.items[0].account_plan_type, None);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -602,6 +653,114 @@ async fn ops_search_should_treat_sql_wildcards_as_literals() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ops_errors_should_use_each_event_accounts_current_subscription() {
|
||||
let Some(database) = TestDatabase::create("ops_account_subscription").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_observability_facts(&database.pool, now)
|
||||
.await
|
||||
.expect("seed observability facts");
|
||||
sqlx::query(
|
||||
"insert into provider_accounts (
|
||||
id, provider_kind, name, email, upstream_user_id, upstream_account_id,
|
||||
plan_type, authentication_kind, provider_credentials_json,
|
||||
credential_revision, credential_observed_at, has_refresh_token,
|
||||
created_at, updated_at
|
||||
) select 'acct_retry', provider_kind, name, email, upstream_user_id,
|
||||
'retry-workspace', 'team', authentication_kind,
|
||||
provider_credentials_json, credential_revision, credential_observed_at,
|
||||
has_refresh_token, created_at, updated_at
|
||||
from provider_accounts where id = 'acct_observe'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("seed same-email retry account");
|
||||
sqlx::query(
|
||||
"update ops_events set provider_account_id = 'acct_retry',
|
||||
provider_account_ref = 'acct_retry'
|
||||
where id = 'ops_observe_retry'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("assign retry event to its own account");
|
||||
let query = admin_observability::OpsErrorQuery {
|
||||
range: admin_observability::TimeRange::new(
|
||||
now - TimeDelta::hours(1),
|
||||
now + TimeDelta::hours(1),
|
||||
)
|
||||
.expect("ops range"),
|
||||
filter: admin_observability::OpsErrorFilter::default(),
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(10).expect("page size"),
|
||||
};
|
||||
let store = admin_observability_store(&database.pool);
|
||||
for plan in [Some("pro"), Some("plus"), None] {
|
||||
sqlx::query("update provider_accounts set plan_type = $1 where id = 'acct_observe'")
|
||||
.bind(plan)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("update current subscription");
|
||||
let page = store
|
||||
.list_ops_errors(query.clone())
|
||||
.await
|
||||
.expect("ops page");
|
||||
assert_eq!(page.total, 2);
|
||||
assert_eq!(page.items.len(), 2);
|
||||
let request = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|error| error.source == "model_request")
|
||||
.expect("request error");
|
||||
let event = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|error| error.source == "ops_event")
|
||||
.expect("retry event");
|
||||
assert_eq!(request.provider_account_email, event.provider_account_email);
|
||||
assert_eq!(request.provider_account_plan_type.as_deref(), plan);
|
||||
assert_eq!(event.provider_account_plan_type.as_deref(), Some("team"));
|
||||
}
|
||||
sqlx::query(
|
||||
"update ops_events set model_request_id = null, attempt_index = null
|
||||
where id = 'ops_observe_retry'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("detach event from request");
|
||||
let page = store
|
||||
.list_ops_errors(query.clone())
|
||||
.await
|
||||
.expect("ops page");
|
||||
assert_eq!(
|
||||
page.items
|
||||
.iter()
|
||||
.find(|error| error.source == "ops_event")
|
||||
.expect("standalone event")
|
||||
.provider_account_plan_type
|
||||
.as_deref(),
|
||||
Some("team")
|
||||
);
|
||||
sqlx::query("delete from provider_accounts where id = 'acct_retry'")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("delete retry account");
|
||||
let page = store
|
||||
.list_ops_errors(query)
|
||||
.await
|
||||
.expect("deleted account history");
|
||||
assert_eq!(page.total, 2);
|
||||
let event = page
|
||||
.items
|
||||
.iter()
|
||||
.find(|error| error.source == "ops_event")
|
||||
.expect("deleted account event");
|
||||
assert_eq!(event.provider_account_ref.as_deref(), Some("acct_retry"));
|
||||
assert_eq!(event.provider_account_plan_type, None);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ops_should_include_incomplete_upstream_errors() {
|
||||
let Some(database) = TestDatabase::create("ops_incomplete_error").await else {
|
||||
@@ -765,7 +924,8 @@ async fn recovered_continuation_failure_should_be_visible_in_ops_but_hidden_from
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("diagnostics without recovered intermediates");
|
||||
.expect("diagnostics without recovered intermediates")
|
||||
.items;
|
||||
assert_eq!(diagnostics[0].request_count, 2);
|
||||
assert_eq!(diagnostics[0].failure_count, 0);
|
||||
|
||||
@@ -1358,7 +1518,8 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
|
||||
admin_observability::DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("admin diagnostics");
|
||||
.expect("admin diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics[0].key, "acct_observe");
|
||||
assert_eq!(diagnostics[0].name, "account@example.invalid");
|
||||
assert_eq!(diagnostics[0].cost_coverage.provider_reported_count, 1);
|
||||
@@ -1775,7 +1936,8 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("usage diagnostics");
|
||||
.expect("usage diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics[0].key, "acct_observe");
|
||||
assert_eq!(diagnostics[0].name, "account@example.invalid");
|
||||
assert_eq!(diagnostics[0].request_count, 3);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use gateway_admin::ports::store::ObservabilityStore as _;
|
||||
use gateway_store::postgres::{
|
||||
DiagnosticDimension, ModelRequestAttemptStart, ModelRequestRepository, NewModelRequest,
|
||||
ObservabilityPageSize, ObservabilityRange, ObservabilityRepository, OpsErrorFilter,
|
||||
@@ -111,7 +112,8 @@ async fn completed_usage_projections_should_accept_statusless_websocket_but_reje
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("load statusless usage diagnostics");
|
||||
.expect("load statusless usage diagnostics")
|
||||
.items;
|
||||
assert_eq!(
|
||||
(
|
||||
diagnostics[0].request_count,
|
||||
@@ -515,7 +517,8 @@ async fn diagnostics_should_group_same_email_accounts_by_stable_ref() {
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account diagnostics");
|
||||
.expect("account diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 2);
|
||||
assert_eq!(
|
||||
diagnostics
|
||||
@@ -587,7 +590,8 @@ async fn diagnostics_should_fallback_to_name_then_ref_for_missing_snapshots() {
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account diagnostics");
|
||||
.expect("account diagnostics")
|
||||
.items;
|
||||
let by_key = diagnostics
|
||||
.iter()
|
||||
.map(|item| (item.key.as_str(), item.name.as_str()))
|
||||
@@ -652,7 +656,8 @@ async fn diagnostics_should_prefer_the_latest_non_null_email_snapshot() {
|
||||
DiagnosticDimension::Account,
|
||||
)
|
||||
.await
|
||||
.expect("account diagnostics");
|
||||
.expect("account diagnostics")
|
||||
.items;
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "acct_snapshot_history");
|
||||
@@ -713,7 +718,8 @@ async fn failure_diagnostics_should_only_include_errored_requests() {
|
||||
DiagnosticDimension::Failure,
|
||||
)
|
||||
.await
|
||||
.expect("failure diagnostics");
|
||||
.expect("failure diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "rate_limited");
|
||||
assert_eq!(diagnostics[0].request_count, 1);
|
||||
@@ -777,7 +783,8 @@ async fn model_diagnostics_should_exclude_provider_endpoints_without_a_model() {
|
||||
DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("model diagnostics");
|
||||
.expect("model diagnostics")
|
||||
.items;
|
||||
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "upstream-model");
|
||||
@@ -838,6 +845,137 @@ async fn tokenless_provider_endpoints_should_remain_in_health_but_not_usage_reco
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_keep_filtered_totals_before_limiting_groups() {
|
||||
let Some(database) = TestDatabase::create("diagnostic_totals").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_account(&database.pool, "acct_totals", "Totals", None, "oauth", now).await;
|
||||
let store = PgExecutionStore::new(database.pool.clone());
|
||||
for index in 0..201 {
|
||||
let id = format!("req_total_{index:03}");
|
||||
let mut first_attempt = attempt(&id, 1, "acct_totals");
|
||||
first_attempt.upstream_model_id = Some(format!("model_{:03}", index % 200));
|
||||
store
|
||||
.insert_model_request_with_first_attempt(new_request(&id, now), first_attempt)
|
||||
.await
|
||||
.expect("insert model group");
|
||||
finalize_request(&database.pool, &id, now).await;
|
||||
}
|
||||
sqlx::query(
|
||||
"update model_requests set cost_source = 'provider_reported', cost_amount = 1,
|
||||
cost_currency = case when id = 'req_total_200' then 'EUR' else 'USD' end",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("set currency groups");
|
||||
let repository = observability_repository(&database.pool);
|
||||
let result = repository
|
||||
.usage_diagnostics(
|
||||
range_around(now),
|
||||
UsageRecordFilter::default(),
|
||||
DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("limited groups");
|
||||
assert_eq!(
|
||||
(
|
||||
result.total_request_count,
|
||||
result.items.len(),
|
||||
result
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.request_count)
|
||||
.sum::<u64>()
|
||||
),
|
||||
(201, 100, 101)
|
||||
);
|
||||
assert_eq!(
|
||||
(result.items[0].key.as_str(), result.items[0].costs.len()),
|
||||
("model_000", 2)
|
||||
);
|
||||
|
||||
let filtered = repository
|
||||
.usage_diagnostics(
|
||||
range_around(now),
|
||||
UsageRecordFilter {
|
||||
model: Some("model_000".to_owned()),
|
||||
..UsageRecordFilter::default()
|
||||
},
|
||||
DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("filtered denominator");
|
||||
assert_eq!((filtered.total_request_count, filtered.items.len()), (2, 1));
|
||||
let empty = repository
|
||||
.usage_diagnostics(
|
||||
range_around(now),
|
||||
UsageRecordFilter::default(),
|
||||
DiagnosticDimension::Failure,
|
||||
)
|
||||
.await
|
||||
.expect("empty error dimension");
|
||||
assert_eq!((empty.total_request_count, empty.items.len()), (0, 0));
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_distinguish_retry_attempts_from_retried_requests() {
|
||||
let Some(database) = TestDatabase::create("diagnostic_retries").await else {
|
||||
return;
|
||||
};
|
||||
let now = Utc::now();
|
||||
seed_account(
|
||||
&database.pool,
|
||||
"acct_retries",
|
||||
"Retries",
|
||||
None,
|
||||
"oauth",
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
let store = PgExecutionStore::new(database.pool.clone());
|
||||
for (index, attempts) in [0, 1, 1, 1, 1, 1, 3, 5].into_iter().enumerate() {
|
||||
let id = format!("req_retry_{index}");
|
||||
store
|
||||
.insert_model_request_with_first_attempt(
|
||||
new_request(&id, now),
|
||||
attempt(&id, 1, "acct_retries"),
|
||||
)
|
||||
.await
|
||||
.expect("insert retry request");
|
||||
finalize_request(&database.pool, &id, now).await;
|
||||
sqlx::query("update model_requests set attempt_count = $2 where id = $1")
|
||||
.bind(id)
|
||||
.bind(attempts)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("set attempts");
|
||||
}
|
||||
let result = super::admin_observability_store(&database.pool)
|
||||
.usage_diagnostics(
|
||||
gateway_admin::model::observability::TimeRange::new(
|
||||
now - TimeDelta::hours(1),
|
||||
now + TimeDelta::hours(1),
|
||||
)
|
||||
.expect("range"),
|
||||
gateway_admin::model::observability::UsageFilter::default(),
|
||||
gateway_admin::model::observability::DiagnosticDimension::Model,
|
||||
)
|
||||
.await
|
||||
.expect("retry metrics through admin adapter");
|
||||
assert_eq!(
|
||||
(
|
||||
result.total_request_count,
|
||||
result.items[0].retry_count,
|
||||
result.items[0].retried_request_count
|
||||
),
|
||||
(8, 6, 2)
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
let Some(database) = TestDatabase::create("snapshot_api_key_dimension").await else {
|
||||
@@ -886,7 +1024,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
DiagnosticDimension::ApiKey,
|
||||
)
|
||||
.await
|
||||
.expect("api key diagnostics");
|
||||
.expect("api key diagnostics")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "key_diag");
|
||||
assert_eq!(diagnostics[0].name, "My Key");
|
||||
@@ -904,7 +1043,8 @@ async fn api_key_diagnostics_should_display_key_name_and_fallback_to_ref() {
|
||||
DiagnosticDimension::ApiKey,
|
||||
)
|
||||
.await
|
||||
.expect("api key diagnostics after deletion");
|
||||
.expect("api key diagnostics after deletion")
|
||||
.items;
|
||||
assert_eq!(diagnostics.len(), 1);
|
||||
assert_eq!(diagnostics[0].key, "key_diag");
|
||||
assert_eq!(diagnostics[0].name, "key_diag");
|
||||
|
||||
@@ -228,7 +228,7 @@ struct PricingRule {
|
||||
|
||||
// 价格来源:https://developers.openai.com/api/docs/pricing。
|
||||
// 只登记已核验的常规价格及服务档位;临时优惠不写入内置价目。
|
||||
// 原有规则于 2026-09-09 核验,新增型号的核验日期见对应条目。
|
||||
// 原有规则于 2026-09-09 核验,GPT-6.1 Sol 于 2026-09-29 核验,其余新增型号见对应条目。
|
||||
// 已按 https://developers.openai.com/api/docs/deprecations 核验至 2026-09-13,
|
||||
// 移除已关闭的型号;仅宣布弃用但尚未到关闭日期的型号继续保留。
|
||||
const PRICING_RULES: &[PricingRule] = &[
|
||||
@@ -244,6 +244,16 @@ const PRICING_RULES: &[PricingRule] = &[
|
||||
.with_long_flex(100_000, 375_000, 10_000)
|
||||
.with_long_fast(400_000, 1_500_000, 40_000),
|
||||
},
|
||||
PricingRule {
|
||||
model: "gpt-6.1-sol",
|
||||
pricing: ModelPricing::new(20_000, 100_000, 1_000)
|
||||
.with_cache_write(125)
|
||||
.with_flex(10_000, 50_000, 500)
|
||||
.with_fast(40_000, 200_000, 2_000)
|
||||
.with_long(40_000, 150_000, 2_000)
|
||||
.with_long_flex(20_000, 75_000, 1_000)
|
||||
.with_long_fast(80_000, 300_000, 4_000),
|
||||
},
|
||||
// https://developers.openai.com/api/docs/models/gpt-6-sol,核验日期 2026-09-24。
|
||||
PricingRule {
|
||||
model: "gpt-6-sol",
|
||||
@@ -864,7 +874,10 @@ fn reasoning_model(model: &str) -> bool {
|
||||
let normalized = normalize_model_name(model);
|
||||
let model = pricing_model_name(&normalized);
|
||||
model.starts_with("gpt-5")
|
||||
|| matches!(model, "gpt-6-astra" | "gpt-6-sol" | "gpt-6-luna")
|
||||
|| matches!(
|
||||
model,
|
||||
"gpt-6-astra" | "gpt-6.1-sol" | "gpt-6-sol" | "gpt-6-luna"
|
||||
)
|
||||
|| model.starts_with("o1")
|
||||
|| model.starts_with("o3")
|
||||
|| model.starts_with("o4")
|
||||
|
||||
@@ -352,7 +352,14 @@ async fn copying_builtin_prices_keeps_cache_read_and_write_fallback_costs() {
|
||||
.await
|
||||
.unwrap();
|
||||
let prices = bundle.admin_provider().pricing_catalog();
|
||||
for model in ["gpt-4", "gpt-4o", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
|
||||
for model in [
|
||||
"gpt-4",
|
||||
"gpt-4o",
|
||||
"gpt-6-astra",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
] {
|
||||
let usage = OpenAiBillingUsage::new(100, 10, 20, 15);
|
||||
let inherited = openai_billing_breakdown(model, usage, None).unwrap();
|
||||
let copied =
|
||||
|
||||
@@ -399,7 +399,7 @@ fn decoder_should_use_non_reasoning_preview_web_search_price() {
|
||||
#[test]
|
||||
fn gpt_6_decoders_should_use_reasoning_preview_web_search_price() {
|
||||
let tools = vec![json!({ "type": "web_search_preview" })];
|
||||
for model in ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"] {
|
||||
for model in ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"] {
|
||||
let created = json!({
|
||||
"type":"response.created",
|
||||
"response":{"id":"resp_preview_search_cost","model":model}
|
||||
|
||||
@@ -80,6 +80,17 @@ fn astra_billing_should_preserve_components_across_tiers_and_context_boundary()
|
||||
fn new_gpt_6_models_should_use_published_prices_across_tiers_and_context_boundary() {
|
||||
// 每档依次核对每百万 Token 的输入、缓存读取、缓存写入和输出价格。
|
||||
for (model, cases) in [
|
||||
(
|
||||
"gpt-6.1-sol",
|
||||
[
|
||||
(None, 272_000, ["2", "0.1", "2.5", "10"]),
|
||||
(None, 272_001, ["4", "0.2", "5", "15"]),
|
||||
(Some("flex"), 272_000, ["1", "0.05", "1.25", "5"]),
|
||||
(Some("flex"), 272_001, ["2", "0.1", "2.5", "7.5"]),
|
||||
(Some("fast"), 272_000, ["4", "0.2", "5", "20"]),
|
||||
(Some("priority"), 272_001, ["8", "0.4", "10", "30"]),
|
||||
],
|
||||
),
|
||||
(
|
||||
"gpt-6-sol",
|
||||
[
|
||||
|
||||
+8
-4
@@ -911,8 +911,6 @@ OAuth start 使用:
|
||||
- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:本周期已记录用量加预计剩余量,
|
||||
公式为 `(本周期已记录用量 + 样本用量 × (100 - usedPercent) / sampledPercent) × 目标窗口秒数 / 源窗口秒数`。
|
||||
真实周期由上游额度重置边界定义,不按自然周/月累计;只有折算结果才乘以目标与源窗口的时长比
|
||||
- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算,
|
||||
公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额
|
||||
- `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、
|
||||
额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及本周期累计
|
||||
已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。
|
||||
@@ -1130,8 +1128,8 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造
|
||||
`dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)。
|
||||
记账和限额比较保留完整精度。
|
||||
日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。
|
||||
手动重置仅清零所选周期的已用金额,保留限额上限、原到期时间和历史费用,返回 `{ id }`。
|
||||
未使用或已过期的窗口不会因手动重置而重新开启。重置前完成但延迟结算的费用不再计入所选周期;
|
||||
手动重置清零所选周期的已用金额并清除到期时间,保留限额上限和历史费用,返回 `{ id }`。
|
||||
所选窗口的重置时间返回 `null`,下次使用时按新建 Key 的规则重新开启。重置前完成但延迟结算的费用不再计入所选周期,也不会开启窗口;
|
||||
重置后完成的请求继续计费,包括重置时仍在进行的请求。操作保留管理员审计,不改变账号上游额度。
|
||||
费用按请求完成时间归属窗口。并发按同一 Key 的执行中请求累计,包含 SSE 与每个 WebSocket
|
||||
`response.create`;空闲连接不占名额,内部重试不重复占用。
|
||||
@@ -1561,6 +1559,12 @@ request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可
|
||||
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
|
||||
同请求量按维度标识稳定排列
|
||||
|
||||
`requestShare` 的分母为该维度筛选后、截取前的全部请求数;`failureClass` 只在带错误类型的请求内计算占比
|
||||
`retryCount` 为额外执行尝试次数之和,`retryRate` 为发生过重试的请求数占该组请求数的比例,同一请求多次重试只计一次
|
||||
|
||||
账号维度诊断项、使用记录列表和错误排查列表的 `accountPlanType` / `accountPlanTypeDisplay` 返回账号当前订阅及展示名称,
|
||||
按各记录的内部账号 ID 关联;订阅未知或账号已删除时为 `null`,不作为请求发生时的订阅快照
|
||||
|
||||
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
|
||||
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属
|
||||
|
||||
|
||||
@@ -556,11 +556,13 @@ Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准
|
||||
日金额、七天金额、并发和 RPM 按 Client Key 跨账号、跨 Provider 合计,零表示不限;修改限额不重置已用金额。
|
||||
Core 负责准入与结算时序,Store 持久化费用账本,Admin 负责限额配置。
|
||||
Admin 的手动重置复用同一账本与 Key 行锁,在一个事务中清零所选周期金额、推进计费起点并写入审计,
|
||||
保留窗口到期时间与费用事件,不推进配置 revision。结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期。
|
||||
关闭所选窗口并保留费用事件,不推进配置 revision,下次使用时重新开启窗口。
|
||||
结算仍按完成时间判断归属,重置前完成的费用不会重新扣入已重置周期或开启窗口。
|
||||
插件预算回调通过 `PluginClientKeyAccess` 进入同一 `ClientKeyService` 和 `ClientKeyStore`,存储事务先复核插件实例版本,
|
||||
再执行原生预算操作;不建立另一份预算状态。查询复用账本投影,不触发准入。局部更新金额上限不改用量或窗口,
|
||||
仅在值变化时提交配置 revision、审计并发布;重置仍不推进配置版本。重复调用及结果未知的合同见
|
||||
[SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)
|
||||
[SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)。插件的外部周期和截止时间属于插件私有状态,展示在插件页面;
|
||||
宿主预算接口和管理页面只表达原生窗口事实,不为插件周期修改滚动规则或重置语义
|
||||
|
||||
- 日窗口按北京时间零点划分;七天窗口从首次准入当天零点开始,到期后由下一次使用重新开启,不固定为周一
|
||||
- 金额优先使用 Provider 上报的 USD,否则按现有模型价格估算;订阅账号的估算费用不代表上游订阅账单。
|
||||
@@ -848,7 +850,8 @@ RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --
|
||||
其他检查与界面验证按 [贡献与审查](../CONTRIBUTING.md#验证) 执行
|
||||
|
||||
插件 Runtime 的真实子进程与持久化测试使用 `CPR_PLUGIN_TEST_DATABASE_URL` 和
|
||||
`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例,密码及隔离要求与上述 Store 测试一致。
|
||||
`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例;未提供插件专用变量时复用 `CPR_TEST_DATABASE_URL` 与 `CPR_TEST_REDIS_URL`。
|
||||
密码及隔离要求与上述 Store 测试一致,CI 缺少服务配置时直接失败。
|
||||
设置 `CPR_PLUGIN_TEST_LIVE_HTTP=1` 会额外请求 GitHub 公共 HTTPS API,验证受管出站链路;这不代表模型推理验收。
|
||||
若测试环境使用 Fake-IP 或私网 DNS,需通过 `CPR_PLUGIN_TEST_LIVE_NETWORK_RANGES` 显式提供逗号分隔的
|
||||
CIDR 授权。该选项只用于真实网络测试,默认为空,不改变生产网络策略或其他测试的授权
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.0",
|
||||
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.47.0",
|
||||
|
||||
Generated
+6
-6
@@ -170,8 +170,8 @@ importers:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
|
||||
'@codex-proxy/ui':
|
||||
specifier: github:zyycn/codex-proxy-ui#v0.3.0
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
|
||||
specifier: github:zyycn/codex-proxy-ui#v0.3.1
|
||||
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
|
||||
'@fontsource-variable/inter':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -384,9 +384,9 @@ packages:
|
||||
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
|
||||
engines: {node: '>= 20.12.0'}
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522':
|
||||
resolution: {gitHosted: true, integrity: sha512-I2h13qjmqABk4IviUXdiZXPU9o//qO6WGbBBvav9rXKXyEjfFXvwvz0ofa/4UWOmTRBhmcRFmEIE0qUTzHZThQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522}
|
||||
version: 0.3.0
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01':
|
||||
resolution: {gitHosted: true, integrity: sha512-TcVbsvwVd+19Ex+o0z3Q6XYx+k2p/lsAWFpCN704zujhz2qqBh9hEp8+aCsA/1kUhLASQki3sXFa5cmpYpTO2g==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01}
|
||||
version: 0.3.1
|
||||
engines: {node: '>=24'}
|
||||
peerDependencies:
|
||||
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2783,7 +2783,7 @@ snapshots:
|
||||
fast-wrap-ansi: 0.2.2
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
|
||||
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/6f6cc09264e5382bf926551f313f7e7c4b7a1b01(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
|
||||
dependencies:
|
||||
'@ant-design/colors': 8.0.1
|
||||
'@ant-design/fast-color': 3.0.1
|
||||
|
||||
@@ -190,10 +190,6 @@ export interface AccountQuotaForecast {
|
||||
estimatedTokensDisplay: string
|
||||
estimatedUsd: number | null
|
||||
estimatedUsdDisplay: string
|
||||
remainingTokens: number | null
|
||||
remainingTokensDisplay: string
|
||||
remainingUsd: number | null
|
||||
remainingUsdDisplay: string
|
||||
}
|
||||
|
||||
export interface AccountQuotaForecastResponse {
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface UsageListRecord {
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
accountNotes: string | null
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
route: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
@@ -276,6 +278,8 @@ export interface OpsError {
|
||||
accountId: string | null
|
||||
accountName: string | null
|
||||
accountEmail: string | null
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
route: string
|
||||
model: string | null
|
||||
requestedModel: string | null
|
||||
@@ -436,6 +440,8 @@ export interface UsageInsightsOverviewResponse {
|
||||
export interface UsageDiagnosticItem {
|
||||
key: string
|
||||
name: string
|
||||
accountPlanType: string | null
|
||||
accountPlanTypeDisplay: string | null
|
||||
requestCount: number
|
||||
successCount: number
|
||||
errorCount: number
|
||||
|
||||
@@ -15,14 +15,13 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
|
||||
const uiStore = useUiStore()
|
||||
const systemUpdateStore = useSystemUpdateStore()
|
||||
const { sidebarCollapsed } = storeToRefs(uiStore)
|
||||
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
|
||||
const { version } = storeToRefs(systemUpdateStore)
|
||||
const { toggleSidebar } = uiStore
|
||||
const route = useRoute()
|
||||
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
|
||||
const mobileSidebarOpen = shallowRef(false)
|
||||
const aboutOpen = shallowRef(false)
|
||||
const systemUpdateOpen = shallowRef(false)
|
||||
const systemUpdateOpening = shallowRef(false)
|
||||
|
||||
function openMobileSidebar() {
|
||||
mobileSidebarOpen.value = true
|
||||
@@ -32,24 +31,6 @@ function closeMobileSidebar() {
|
||||
mobileSidebarOpen.value = false
|
||||
}
|
||||
|
||||
async function openSystemUpdate() {
|
||||
if (systemUpdateOpen.value || systemUpdateOpening.value)
|
||||
return
|
||||
|
||||
systemUpdateOpening.value = true
|
||||
try {
|
||||
if (!loadedOnce.value)
|
||||
await systemUpdateStore.loadSystem(false)
|
||||
}
|
||||
catch {
|
||||
// 弹窗打开后由弹窗内的加载逻辑提示失败原因。
|
||||
}
|
||||
finally {
|
||||
systemUpdateOpening.value = false
|
||||
systemUpdateOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void systemUpdateStore.loadVersion().catch(() => undefined)
|
||||
})
|
||||
@@ -75,7 +56,7 @@ watch(
|
||||
:collapsed="sidebarCollapsed"
|
||||
@toggle="toggleSidebar"
|
||||
@open-about="aboutOpen = true"
|
||||
@open-system-update="openSystemUpdate"
|
||||
@open-system-update="systemUpdateOpen = true"
|
||||
/>
|
||||
<FloatingSidebarToggle v-if="!mobileSidebarOpen" @open="openMobileSidebar" />
|
||||
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
|
||||
@@ -103,7 +84,7 @@ watch(
|
||||
@close="closeMobileSidebar"
|
||||
@navigate="closeMobileSidebar"
|
||||
@open-about="aboutOpen = true"
|
||||
@open-system-update="openSystemUpdate"
|
||||
@open-system-update="systemUpdateOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
const updateError = shallowRef('')
|
||||
const statusAvailable = shallowRef(false)
|
||||
const policy = shallowRef<SystemUpdatePolicy | null>(null)
|
||||
const loadedOnce = shallowRef(false)
|
||||
const updateLogs = ref<SystemUpdateEvent[]>([])
|
||||
const updateStreaming = shallowRef(false)
|
||||
const updateStreamError = shallowRef('')
|
||||
@@ -282,7 +281,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
return
|
||||
updateInfo.value = detail
|
||||
policy.value = detail.policy
|
||||
loadedOnce.value = true
|
||||
if (!version.value)
|
||||
await loadVersion()
|
||||
if (generation !== detailGeneration)
|
||||
@@ -501,7 +499,6 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
|
||||
updateError,
|
||||
lastFailedOperation,
|
||||
needRestart,
|
||||
loadedOnce,
|
||||
updateLogs,
|
||||
updateStreaming,
|
||||
updateStreamError,
|
||||
|
||||
@@ -18,6 +18,7 @@ const props = withDefaults(
|
||||
|
||||
const planPalettes: Record<string, string> = {
|
||||
free: 'bg-cp-cyan-container text-cp-cyan-on-container',
|
||||
plus: 'bg-cp-blue-container-strong text-cp-blue-on-container',
|
||||
pro: 'bg-cp-purple-container-strong text-cp-purple-on-container',
|
||||
prolite: 'bg-cp-purple-container text-cp-purple-on-container',
|
||||
}
|
||||
|
||||
+2
-12
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountQuotaForecast } from '@/api'
|
||||
import { ArrowRight, Info } from '@lucide/vue'
|
||||
import { ArrowRight } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ forecast: AccountQuotaForecast }>()
|
||||
@@ -26,8 +26,7 @@ const metrics = computed(() => [
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-cp-xs text-cp-text-secondary">本周期已用</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
|
||||
<Info class="size-3" aria-hidden="true" />
|
||||
<span class="inline-flex items-center rounded-cp-sm bg-cp-info-container px-2 py-1 text-cp-xs font-emphasis text-cp-info-on-container">
|
||||
{{ forecast.lowSample ? '初步估算 · 仅供参考' : '估算值 · 仅供参考' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,14 +69,5 @@ const metrics = computed(() => [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
|
||||
<span class="text-cp-text-secondary">更新时的剩余额度</span>
|
||||
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
|
||||
{{ forecast.remainingTokensDisplay }} Tokens
|
||||
<span class="mx-1 text-cp-text-tertiary">/</span>
|
||||
{{ forecast.remainingUsdDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BaseSkeleton } from '@codex-proxy/ui'
|
||||
<div role="status" aria-busy="true">
|
||||
<span class="sr-only">正在加载额度预测</span>
|
||||
<div aria-hidden="true">
|
||||
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
|
||||
<div class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<BaseSkeleton shape="text" class="w-16" />
|
||||
@@ -33,11 +33,6 @@ import { BaseSkeleton } from '@codex-proxy/ui'
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-3">
|
||||
<BaseSkeleton shape="text" class="w-28" />
|
||||
<BaseSkeleton shape="text" class="w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { now, pause, resume } = useNow({
|
||||
scheduler: callback => useIntervalFn(callback, 30_000),
|
||||
})
|
||||
const options = computed(() => report.value?.forecasts.map(item => ({
|
||||
label: item.extrapolated ? `${item.targetDays}天折算` : item.source?.label ?? (item.period === 'weekly' ? '周额度' : '月额度'),
|
||||
label: item.period === 'weekly' ? '周额度' : '月额度',
|
||||
value: item.period,
|
||||
})) ?? [
|
||||
{ label: '周额度', value: 'weekly' },
|
||||
@@ -75,9 +75,9 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<AccountIdentityCell :account="account" show-plan title-mode="email" class="max-w-full">
|
||||
<AccountIdentityCell :account="account" show-plan title-mode="email" meta-position="secondary" meta-size="xs" class="max-w-full">
|
||||
<template #meta>
|
||||
<ProviderIconGroup :provider="account.provider" size="sm" />
|
||||
<ProviderIconGroup :provider="account.provider" size="xs" />
|
||||
</template>
|
||||
</AccountIdentityCell>
|
||||
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
|
||||
@@ -104,7 +104,10 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="mr-auto flex min-w-0 items-center gap-2">
|
||||
<div class="mr-auto flex min-w-0 items-center gap-1">
|
||||
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
|
||||
更新于 {{ forecast.source.observedAtDisplay }}
|
||||
</span>
|
||||
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
|
||||
<template #trigger>
|
||||
<BaseIconButton
|
||||
@@ -132,16 +135,13 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
目前数据还较少,结果可能有较大波动
|
||||
</p>
|
||||
<p v-if="forecast?.extrapolated" class="m-0">
|
||||
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算
|
||||
本页预测由{{ forecast.source?.label ?? '当前周期' }}按 {{ forecast.targetDays }} 天折算
|
||||
</p>
|
||||
<p class="m-0">
|
||||
剩余量以数据更新时间为准,等价费用不是实际账单或账户余额
|
||||
预测以数据更新时间为准,等价费用不是实际账单或账户余额
|
||||
</p>
|
||||
</section>
|
||||
</BasePopover>
|
||||
<span v-if="forecast?.source?.observedAt" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
|
||||
更新于 {{ forecast.source.observedAtDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
<BaseButton variant="secondary" @click="open = false">
|
||||
关闭
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import type { AccountRow } from '../../constants'
|
||||
import { BaseEmpty, BaseIconButton } from '@codex-proxy/ui'
|
||||
|
||||
import { RefreshCw, UserRound } from '@lucide/vue'
|
||||
import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
|
||||
import AccountPlanBadge from '../AccountPlanBadge.vue'
|
||||
import AccountProfileModal from '../AccountProfileModal/index.vue'
|
||||
import AccountQuotaForecastModal from '../AccountQuotaForecastModal/index.vue'
|
||||
import AccountQuotaPanelEntry from './Entry.vue'
|
||||
import AccountResetCredits from './ResetCredits.vue'
|
||||
|
||||
@@ -17,6 +18,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accountUpdated: [account: AccountRow]
|
||||
refreshQuota: [accountId: string]
|
||||
quotaReset: [accountId: string]
|
||||
}>()
|
||||
@@ -25,8 +27,10 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
|
||||
orderedPanelQuotaWindows(props.account.quota.windows),
|
||||
))
|
||||
const profileOpen = shallowRef(false)
|
||||
const forecastOpen = shallowRef(false)
|
||||
const hasForecast = computed(() => props.account.authenticationKind !== 'api_key')
|
||||
const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription)
|
||||
const hasActions = computed(() => hasPersonalInfo.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits)
|
||||
const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || props.account.capabilities.quotaRefresh || props.account.capabilities.resetCredits)
|
||||
watch(hasPersonalInfo, (available) => {
|
||||
if (!available)
|
||||
profileOpen.value = false
|
||||
@@ -69,6 +73,17 @@ watch(hasPersonalInfo, (available) => {
|
||||
:account="account"
|
||||
@consumed="emit('quotaReset', $event)"
|
||||
/>
|
||||
<BaseIconButton
|
||||
v-if="hasForecast"
|
||||
label="预测周/月额度"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-haspopup="dialog"
|
||||
:pressed="forecastOpen"
|
||||
@click="forecastOpen = true"
|
||||
>
|
||||
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton
|
||||
v-if="account.capabilities.quotaRefresh"
|
||||
variant="ghost"
|
||||
@@ -107,4 +122,10 @@ watch(hasPersonalInfo, (available) => {
|
||||
v-model="profileOpen"
|
||||
:account="account"
|
||||
/>
|
||||
<AccountQuotaForecastModal
|
||||
v-if="hasForecast"
|
||||
v-model="forecastOpen"
|
||||
:account="account"
|
||||
@account-updated="emit('accountUpdated', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountRow } from '../constants'
|
||||
|
||||
import { BaseIconButton, BaseTable, defineTableColumns } from '@codex-proxy/ui'
|
||||
import { ChartNoAxesCombined, Sigma } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { BaseTable, defineTableColumns } from '@codex-proxy/ui'
|
||||
import { Sigma } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { modelSuccessRateTextClass } from '../constants'
|
||||
import AccountQuotaForecastModal from './AccountQuotaForecastModal/index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
account: AccountRow
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accountUpdated: [account: AccountRow]
|
||||
}>()
|
||||
const forecastOpen = ref(false)
|
||||
|
||||
type AccountModelUsage = AccountRow['usage']['models'][number]
|
||||
|
||||
const totalBilling = computed(() => props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'))
|
||||
@@ -80,21 +74,9 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
|
||||
|
||||
<div class="min-w-0 pt-4 xl:flex xl:min-h-0 xl:flex-col xl:pt-0 xl:pl-4">
|
||||
<div class="mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
|
||||
模型使用排行
|
||||
</h3>
|
||||
<BaseIconButton
|
||||
v-if="account.authenticationKind !== 'api_key'"
|
||||
label="预测周/月额度"
|
||||
size="sm"
|
||||
class="h-5 w-5"
|
||||
aria-haspopup="dialog"
|
||||
@click="forecastOpen = true"
|
||||
>
|
||||
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
|
||||
模型使用排行
|
||||
</h3>
|
||||
|
||||
<div class="ml-auto flex items-baseline gap-4">
|
||||
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
@@ -134,11 +116,4 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AccountQuotaForecastModal
|
||||
v-if="account.authenticationKind !== 'api_key'"
|
||||
v-model="forecastOpen"
|
||||
:account="account"
|
||||
@account-updated="emit('accountUpdated', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
|
||||
format: value => accountProviderLabel(typeof value === 'string' ? value : null),
|
||||
},
|
||||
{ key: 'status', label: '状态', kind: 'status', align: 'left', sortable: true },
|
||||
{ key: 'planType', label: '套餐', kind: 'status', sortable: true },
|
||||
{ key: 'planType', label: '订阅', kind: 'status', sortable: true },
|
||||
{ key: 'usage', label: '用量', kind: 'custom', size: '2xl', sortable: true },
|
||||
{ key: 'groups', label: '账号分组', kind: 'status' },
|
||||
{
|
||||
|
||||
@@ -344,13 +344,11 @@ const {
|
||||
<AccountQuotaPanel
|
||||
:account="row"
|
||||
:refreshing="refreshingQuotaAccountIds.has(row.id)"
|
||||
@account-updated="void replaceAccount($event)"
|
||||
@quota-reset="handleQuotaReset"
|
||||
@refresh-quota="handleRefreshQuota"
|
||||
/>
|
||||
<AccountUsagePanel
|
||||
:account="row"
|
||||
@account-updated="void replaceAccount($event)"
|
||||
/>
|
||||
<AccountUsagePanel :account="row" />
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
@@ -46,7 +46,7 @@ async function resetBudget() {
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="重置已用额度"
|
||||
description="清零所选周期的已用金额,原重置时间保持不变"
|
||||
description="清零所选周期的已用金额,下次使用时重新确定重置日期"
|
||||
size="sm"
|
||||
:dismissible="!loading"
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="block min-w-0 truncate font-mono text-cp-sm font-emphasis text-cp-text">
|
||||
<code class="block min-w-0 overflow-hidden whitespace-nowrap font-mono text-cp-sm font-emphasis text-cp-text">
|
||||
{{ prefix }}••••••••••••••••
|
||||
</code>
|
||||
<BaseIconButton
|
||||
|
||||
@@ -30,7 +30,7 @@ const { accounts, loading, error, pagination, search, setPage, setPageSize, remo
|
||||
const columns = defineTableColumns<OutboundProxyAccount>([
|
||||
{ key: 'identity', label: '账号', kind: 'identity', size: '3xl' },
|
||||
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'md', align: 'center' },
|
||||
{ key: 'plan', label: '套餐', kind: 'custom', size: 'sm', align: 'center' },
|
||||
{ key: 'plan', label: '订阅', kind: 'custom', size: 'sm', align: 'center' },
|
||||
{ key: 'groups', label: '分组', kind: 'custom', size: 'lg', align: 'center' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'xs', align: 'center' },
|
||||
])
|
||||
|
||||
@@ -118,6 +118,8 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord
|
||||
accountEmail: options.accountEmail,
|
||||
accountName: options.accountEmail.split('@')[0] ?? null,
|
||||
accountNotes: null,
|
||||
accountPlanType: null,
|
||||
accountPlanTypeDisplay: null,
|
||||
route: stream ? '/v1/responses' : '/v1/chat/completions',
|
||||
model: options.model,
|
||||
requestedModel: options.model,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BaseIconButton, BaseInput, BaseTable, BaseTableColumnSettings, BaseTabl
|
||||
import { Eye, RefreshCw, Search } from '@lucide/vue'
|
||||
import { shallowRef, toRef } from 'vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
|
||||
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
|
||||
import { opsErrorColumns } from '../constants'
|
||||
import { opsErrorSummary } from '../utils/opsErrorPresentation'
|
||||
@@ -152,6 +153,15 @@ function upstreamSendStateText(value: string | null | undefined) {
|
||||
:authentication-kind="row.authenticationKind"
|
||||
/>
|
||||
</template>
|
||||
<template #accountPlanType="{ row }">
|
||||
<AccountPlanBadge
|
||||
v-if="row.accountPlanType"
|
||||
:plan-type="row.accountPlanType"
|
||||
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-else class="text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
<template #message="{ row }">
|
||||
<div class="min-w-0 py-0.5" :title="row.message || opsErrorSummary(row)">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumnSize } from '@codex-proxy/ui'
|
||||
import type { getUsageRecordInsightsDiagnostics } from '@/api'
|
||||
import { BaseCard, BaseEmpty, BaseSegmented, BaseTable, defineTableColumns } from '@codex-proxy/ui'
|
||||
|
||||
import { CornerDownRight } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/format'
|
||||
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
|
||||
|
||||
import { formatDuration, formatPercent, formatUsd } from '../utils/format'
|
||||
|
||||
@@ -31,24 +33,43 @@ const dimensionOptions = [
|
||||
{ label: '错误', value: 'failureClass' },
|
||||
]
|
||||
|
||||
const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
|
||||
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
|
||||
const resultDimensionLabel = computed(
|
||||
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
|
||||
)
|
||||
|
||||
const dimensionNameSizes: Record<string, TableColumnSize> = {
|
||||
model: 'xl',
|
||||
account: '2xl',
|
||||
apiKey: 'md',
|
||||
provider: 'lg',
|
||||
transport: 'sm',
|
||||
failureClass: 'xl',
|
||||
}
|
||||
|
||||
const diagnosticColumns = computed(() => defineTableColumns<DiagnosticDisplayItem>([
|
||||
{
|
||||
key: 'nameDisplay',
|
||||
label: '维度',
|
||||
kind: 'custom',
|
||||
size: 'xl',
|
||||
size: dimensionNameSizes[resultDimension.value] ?? 'xl',
|
||||
},
|
||||
...(resultDimension.value === 'account'
|
||||
? defineTableColumns<DiagnosticDisplayItem>([
|
||||
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
|
||||
])
|
||||
: []),
|
||||
{
|
||||
key: 'requestCount',
|
||||
label: '请求',
|
||||
kind: 'numeric',
|
||||
size: 'sm',
|
||||
size: 'xs',
|
||||
},
|
||||
{
|
||||
key: 'errorCount',
|
||||
label: '失败',
|
||||
kind: 'numeric',
|
||||
size: 'sm',
|
||||
size: 'xs',
|
||||
},
|
||||
{ key: 'firstTokenP95Ms', label: '性能', kind: 'numeric', size: 'lg' },
|
||||
{
|
||||
@@ -57,12 +78,7 @@ const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
|
||||
kind: 'numeric',
|
||||
size: 'sm',
|
||||
},
|
||||
])
|
||||
|
||||
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
|
||||
const resultDimensionLabel = computed(
|
||||
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
|
||||
)
|
||||
]))
|
||||
|
||||
type DiagnosticDisplayItem = Diagnostics['items'][number] & {
|
||||
nameDisplay: ReturnType<typeof diagnosticNameDisplay>
|
||||
@@ -98,7 +114,7 @@ function diagnosticNameDisplay(name: string) {
|
||||
as="article"
|
||||
title="热点诊断"
|
||||
:description="`按${resultDimensionLabel}聚合`"
|
||||
class="h-105 min-h-105 max-h-105 min-w-0 w-full lg:h-full lg:min-h-90 lg:max-h-105"
|
||||
class="max-h-105 min-w-0 w-full xl:h-full"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseSegmented
|
||||
@@ -114,7 +130,7 @@ function diagnosticNameDisplay(name: string) {
|
||||
<BaseTable
|
||||
v-if="hasData"
|
||||
:key="resultDimension"
|
||||
class="min-h-0 w-full xl:contain-[size]"
|
||||
class="min-h-0 max-h-80 w-full xl:max-h-none xl:contain-[size]"
|
||||
:columns="diagnosticColumns"
|
||||
:rows="displayItems"
|
||||
density="compact"
|
||||
@@ -148,6 +164,16 @@ function diagnosticNameDisplay(name: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #accountPlanType="{ row }">
|
||||
<AccountPlanBadge
|
||||
v-if="row.accountPlanType"
|
||||
:plan-type="row.accountPlanType"
|
||||
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-else class="text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
|
||||
<template #requestCount="{ row }">
|
||||
<span
|
||||
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
|
||||
@@ -205,7 +231,7 @@ function diagnosticNameDisplay(name: string) {
|
||||
surface="none"
|
||||
:title="loading ? '正在加载热点诊断数据' : '暂无诊断数据'"
|
||||
description="当前范围没有可诊断的请求记录"
|
||||
class="h-full place-content-center"
|
||||
class="h-full min-h-40 place-content-center"
|
||||
/>
|
||||
</template>
|
||||
</BaseCard>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UsageDisplayRecord } from '../utils/records'
|
||||
import { BaseTable } from '@codex-proxy/ui'
|
||||
import { Minimize2 } from '@lucide/vue'
|
||||
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
|
||||
import AccountPlanBadge from '@/views/accounts/components/AccountPlanBadge.vue'
|
||||
import {
|
||||
usageAccountText,
|
||||
usageAuthenticationKind,
|
||||
@@ -72,6 +73,16 @@ withDefaults(
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #accountPlanType="{ row }">
|
||||
<AccountPlanBadge
|
||||
v-if="row.accountPlanType"
|
||||
:plan-type="row.accountPlanType"
|
||||
:plan-type-display="row.accountPlanTypeDisplay || row.accountPlanType"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-else class="text-cp-text-quaternary">—</span>
|
||||
</template>
|
||||
|
||||
<template #clientIp="{ row }">
|
||||
<UsageClientIpCell :record="row" />
|
||||
</template>
|
||||
|
||||
@@ -14,14 +14,16 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
|
||||
kind: 'identity',
|
||||
size: '3xl',
|
||||
},
|
||||
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
|
||||
{
|
||||
key: 'provider',
|
||||
label: '平台/类型',
|
||||
kind: 'status',
|
||||
size: 'sm',
|
||||
size: 'md',
|
||||
fixedWidth: true,
|
||||
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
|
||||
},
|
||||
{ key: 'model', label: '模型', kind: 'custom', size: 'lg' },
|
||||
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
|
||||
{ key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
|
||||
{ key: 'route', label: '端点', kind: 'mono' },
|
||||
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
|
||||
@@ -38,6 +40,7 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
|
||||
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
|
||||
{ key: 'clientApiKeyName', label: '密钥名称', kind: 'identity', size: 'xl' },
|
||||
{ key: 'accountId', label: '账号', kind: 'identity', size: '3xl', emptyText: '未知账号', hideable: false },
|
||||
{ key: 'accountPlanType', label: '订阅', kind: 'status', size: 'sm', fixedWidth: true },
|
||||
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
|
||||
{ key: 'message', label: '错误', kind: 'custom', size: '4xl', hideable: false },
|
||||
{ key: 'upstreamSendState', label: '发送状态', kind: 'custom', size: 'xl' },
|
||||
|
||||
+1
-1
Submodule modules/plugins updated: 073ad98cd0...7a04904ffc
+1
-1
Submodule modules/ui updated: e1d4a164e7...6f6cc09264
+13
-9
@@ -1,15 +1,19 @@
|
||||
# v3.18.1
|
||||
# v3.18.3
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 在线更新不再因插件不兼容而阻止安装,点击重启时检查已安装目标版本,列出不兼容插件的名称与原因,确认后停用这些插件并重启
|
||||
- 确认绑定目标发行和插件配置,检查后发生变更时要求重新确认;取消不修改插件状态,批量停用保留配置、密钥与私有数据
|
||||
- 插件管理使用黄色警告三角展示不兼容原因,禁止启动不兼容插件,安装兼容版本后可重新启用
|
||||
- 兼容更新文件已安装、旧进程尚未重启时的管理端操作,保留旧版本的重启入口
|
||||
- 重置 API 密钥额度后,清零所选周期用量并清除原重置时间,下次使用时重新确定重置日期;保留额度上限、密钥和历史费用记录
|
||||
- 重置前完成但延迟落盘的费用不会重新扣入已重置周期,也不会提前开启新窗口;重置后完成的在途请求仍正常计费
|
||||
- 修复出站 HTTP/2 请求重复携带 Host 信息导致部分服务拒绝请求的问题,覆盖插件来源下载;保留 HTTP 正向代理所需的原始 Host
|
||||
|
||||
## 管理界面
|
||||
|
||||
- 将周/月额度预测入口移到账号额度面板,方便与当前额度一起查看
|
||||
- 错误日志增加账号当前订阅列,账号、代理关联账号和使用记录等页面统一使用“订阅”标签
|
||||
- 按热点诊断维度调整列宽,改善账号信息与请求、失败、性能、费用列的空间分配
|
||||
|
||||
## 升级说明
|
||||
|
||||
- 从 v3.17.0 升级:插件已卸载或全部停用时,可直接在线更新到本版;仍启用旧合同插件时,需先停用一次,旧进程不能提前使用新版确认流程
|
||||
- 本版发行清单保留 v3.17.0 可解析的传输格式,实际支持范围仍为清单 2、进程协议 2、中间件能力 3,不宣称兼容旧插件
|
||||
- v3.18.0 的清单解析规则与 v3.17.0 互斥,无法读取此过渡格式;已运行 v3.18.0 的实例需用完整平台发行包或容器镜像升级一次。二进制部署须成套替换程序、Web 资源和 `plugins/official` 目录
|
||||
- 进入本版后的后续在线更新使用重启前检查与确认流程,Workbench 可安装 [v0.2.0](https://github.com/zyycn/codex-proxy-plugins/releases/tag/v0.2.0)
|
||||
- 本次不新增或修改数据库迁移
|
||||
- 插件的预算重置接口与管理员操作保持一致,下次使用再计时;依赖重置后保留原截止时间的插件需调整调用逻辑,结果未知时不能盲目重复重置
|
||||
- 错误日志的订阅列展示账号当前订阅,不是请求发生时的历史快照
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.18.1"
|
||||
version: "3.18.3"
|
||||
|
||||
Reference in New Issue
Block a user