fix(groups): attribute usage costs by serving account membership

This commit is contained in:
Ruichen Zhou
2026-09-21 11:19:03 +02:00
parent debba0e13c
commit 6f16c8efd1
3 changed files with 33 additions and 12 deletions
@@ -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'
@@ -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<String> = 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 {
+2
View File
@@ -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