From 6f16c8efd1dbb58dee682d51428f8843c7fbb48a Mon Sep 17 00:00:00 2001 From: Ruichen Zhou Date: Mon, 21 Sep 2026 11:19:03 +0200 Subject: [PATCH] fix(groups): attribute usage costs by serving account membership --- .../src/postgres/account_groups.rs | 6 ++- .../tests/postgres/account_groups.rs | 37 +++++++++++++------ docs/api.md | 2 + 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 394ff76d1..9e7960bff 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -528,6 +528,8 @@ async fn group_costs( return Ok(BTreeMap::new()); } let completed_usage = completed_usage_fact_predicate("mr"); + // 用量按实际完成请求的账号统计:账号属于多个分组时计入每个所属分组, + // 不再按 Client Key 绑定分组快照归属,避免多分组 Key 的费用重复出现在未承接请求的分组上。 let statement = format!( "with requested_groups(group_id) as ( select unnest($1::text[]) @@ -540,8 +542,10 @@ async fn group_costs( coalesce(sum(mr.cost_amount), 0)::text as retained_total_usd from requested_groups cross join runtime_settings settings + left join account_group_accounts membership + on membership.account_group_id = requested_groups.group_id left join model_requests mr - on mr.routing_group_refs @> array[requested_groups.group_id]::text[] + on mr.provider_account_ref = membership.provider_account_id and mr.started_at >= now() - make_interval(days => settings.usage_retention_days::int) and {completed_usage} and mr.cost_currency = 'USD' diff --git a/backend/crates/gateway-store/tests/postgres/account_groups.rs b/backend/crates/gateway-store/tests/postgres/account_groups.rs index fa2c75b4f..77877dcd4 100644 --- a/backend/crates/gateway-store/tests/postgres/account_groups.rs +++ b/backend/crates/gateway-store/tests/postgres/account_groups.rs @@ -89,14 +89,24 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip .await .expect("create scoped client key"); } + // 多分组 Key 的请求费用只计入实际承接账号所属的分组,Key 绑定的其他分组不重复计费。 seed_group_cost_snapshot( &database.pool, - "req_historical_empty_group", + "req_dual_group_key", "acct_group_openai", - EMPTY_GROUP, + &[MIXED_GROUP, EMPTY_GROUP], "1.5", ) .await; + // 归属跟随完成请求的账号,而不是 Client Key 绑定的分组快照。 + seed_group_cost_snapshot( + &database.pool, + "req_account_attribution", + "acct_group_xai", + &[EMPTY_GROUP], + "2.25", + ) + .await; let page = groups .list_account_groups(AccountGroupListQuery { @@ -135,14 +145,14 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip assert_eq!(mixed.account_summary.total, 0); assert_eq!(mixed.capacity.used_slots, None); assert_eq!(mixed.capacity.total_slots, 0); - assert_eq!(mixed.usage.today_usd.as_str(), "0"); - assert_eq!(mixed.usage.retained_total_usd.as_str(), "0"); + assert_eq!(mixed.usage.today_usd.as_str(), "3.75"); + assert_eq!(mixed.usage.retained_total_usd.as_str(), "3.75"); let empty = by_id.get(EMPTY_GROUP).expect("empty group"); assert_eq!(empty.member_count, 0); assert!(empty.provider_counts.is_empty()); assert_eq!(empty.client_key_count, 1); - assert_eq!(empty.usage.today_usd.as_str(), "1.5"); - assert_eq!(empty.usage.retained_total_usd.as_str(), "1.5"); + assert_eq!(empty.usage.today_usd.as_str(), "0"); + assert_eq!(empty.usage.retained_total_usd.as_str(), "0"); let all_key = keys .reveal_client_key(&client_key_id("key_all_accounts")) @@ -271,6 +281,7 @@ async fn group_costs_should_include_statusless_websocket_but_reject_statusless_h ) .await .expect("create statusless cost group"); + assign_accounts(&database.pool, EMPTY_GROUP, &["acct_group_statusless"]).await; for (request_id, cost_amount) in [ ("req_group_http_success", "1.5"), ("req_group_statusless_websocket", "2"), @@ -280,7 +291,7 @@ async fn group_costs_should_include_statusless_websocket_but_reject_statusless_h &database.pool, request_id, "acct_group_statusless", - EMPTY_GROUP, + &[EMPTY_GROUP], cost_amount, ) .await; @@ -393,9 +404,13 @@ async fn seed_group_cost_snapshot( pool: &sqlx::PgPool, request_id: &str, account_id: &str, - historical_group_id: &str, + routing_group_ids: &[&str], cost_amount: &str, ) { + let routing_group_refs: Vec = routing_group_ids + .iter() + .map(|id| (*id).to_owned()) + .collect(); sqlx::query( "insert into model_requests ( id, client_api_key_ref, config_revision, protocol, operation, endpoint, @@ -411,16 +426,16 @@ async fn seed_group_cost_snapshot( 'sent', now(), 'succeeded', 200, 200, 10, 'provider_reported', $4::numeric, 'USD', now() - interval '1 minute', now() + interval '5 minutes', now(), - 'groups', array[$3]::text[], jsonb_build_array($3::text) + 'groups', $3::text[], to_jsonb($3::text[]) )", ) .bind(request_id) .bind(account_id) - .bind(historical_group_id) + .bind(routing_group_refs) .bind(cost_amount) .execute(pool) .await - .expect("seed historical group cost snapshot"); + .expect("seed group cost snapshot"); } async fn current_revision(pool: &sqlx::PgPool) -> u64 { diff --git a/docs/api.md b/docs/api.md index 234124dc1..7f98482b2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -930,6 +930,8 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 列表数据为 `{ items, page, configRevision }`,其中 item 返回 `memberCount`、按 Provider 聚合的 `providerCounts` 和 `clientKeyCount`。查询分组成员使用账号列表的 `groupId` 筛选, 不提供独立的分组成员路由;账号的 Provider 不代表整个分组的 Provider。 +`usage` 的 `todayUsd` 和 `retainedTotalUsd` 按实际完成请求的账号统计,计入该账号当前所属的每个分组; +账号属于多个分组时各分组均计入,与 Client Key 绑定了哪些分组无关。 ## 7. Client Key