feat: add selectable routing groups and composite billing

Support per-model provider enablement and compact model editing. Capture request-time billing factors, charge customer costs separately, and preserve historical statistics without backfills.
This commit is contained in:
elky
2026-10-07 14:49:57 +08:00
parent 310098a853
commit 911c7f8875
110 changed files with 6524 additions and 559 deletions
@@ -7733,6 +7733,7 @@ impl<'a> AdminAppState<'a> {
feature_settings: key
.contains_key("feature_settings")
.then(|| feature_settings.clone()),
routing_group_selection: None,
},
)
.await?;
@@ -411,7 +411,7 @@ async fn dry_run_routing_group(
let headers_json = payload.headers.unwrap_or_else(|| json!({}));
let mut header_map = header_map_from_value(&headers_json)?;
let mut body = payload.body.unwrap_or_else(|| json!({}));
let policy = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
let mut policy = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
group_id: Some(group.id.as_str()),
group_version: Some(group.version),
group_config_json: &group.config_json,
@@ -425,6 +425,7 @@ async fn dry_run_routing_group(
body: &body,
phase: payload.phase.unwrap_or(RoutingRulePhase::ClientRequest),
})?;
policy.group_name = Some(group.name.clone());
let patch_summary = patch_summary(&policy.mutation_plan);
apply_routing_mutation_plan(&mut body, &mut header_map, &policy.mutation_plan)?;
let mut trace = build_routing_trace_seed(&policy, api_format);
@@ -125,6 +125,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
concurrent_limit_present,
ip_rules,
feature_settings,
routing_group_selection: None,
})
.await?
else {
@@ -32,6 +32,9 @@ use user_me_usage::*;
#[path = "user_me_catalog.rs"]
mod user_me_catalog;
use user_me_catalog::*;
#[path = "user_me_routing_groups.rs"]
mod user_me_routing_groups;
use user_me_routing_groups::*;
#[path = "user_me_preferences.rs"]
mod user_me_preferences;
use user_me_preferences::*;
@@ -0,0 +1,351 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::routing_profiles::RoutingGroupLookupKey;
use axum::{body::Body, http::StatusCode, response::Response};
use serde::Deserialize;
use serde_json::{Map, Value};
use super::{build_auth_error_response, normalize_feature_settings, AppState};
use crate::routing::selection::routing_group_is_user_visible;
const ROUTING_GROUP_ID: &str = "routing_group_id";
pub(super) fn deserialize_routing_group_patch<'de, D>(
deserializer: D,
) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(Some)
}
pub(super) fn api_key_routing_group_id(settings: Option<&Value>) -> Option<&str> {
settings
.and_then(|value| value.get(ROUTING_GROUP_ID))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(super) async fn validate_routing_group_patch(
state: &AppState,
current: Option<&Value>,
requested: Option<Option<String>>,
) -> Result<Option<Option<String>>, Response<Body>> {
let Some(Some(requested)) = requested else {
return Ok(requested);
};
let id = requested.trim();
if id.is_empty() || id.len() > 128 {
return Err(build_auth_error_response(
StatusCode::BAD_REQUEST,
"routing_group_id 必须是有效的策略分组 ID;跟随默认请传 null",
false,
));
}
// A group can become hidden or disabled after selection. An unrelated edit
// (including a form resubmitting its unchanged selection) must remain valid.
if api_key_routing_group_id(current) == Some(id) {
return Ok(Some(Some(id.to_string())));
}
if !state.has_routing_group_data_reader() {
return Err(build_auth_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"策略分组目录暂不可用",
false,
));
}
let group = state
.find_routing_group(RoutingGroupLookupKey::Id(id))
.await
.map_err(|error| {
build_auth_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("user API key routing group lookup failed: {error:?}"),
false,
)
})?;
if !group
.as_ref()
.is_some_and(|group| group.enabled && routing_group_is_user_visible(group))
{
return Err(build_auth_error_response(
StatusCode::BAD_REQUEST,
"所选策略分组不存在或当前不可选",
false,
));
}
Ok(Some(Some(id.to_string())))
}
/// Compose the initial create record. Existing-key updates must use the atomic
/// repository routing selection patch instead of merging a pre-read snapshot.
pub(super) fn merge_api_key_feature_settings(
current: Option<&Value>,
incoming: Option<Option<Value>>,
routing_group_patch: Option<Option<String>>,
) -> Result<Option<Option<Value>>, String> {
if incoming.is_none() && routing_group_patch.is_none() {
return Ok(None);
}
let mut settings = normalize_feature_settings(incoming.unwrap_or_else(|| current.cloned()))?
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
settings.remove(ROUTING_GROUP_ID);
settings.remove("routing_group_name");
let group_id = routing_group_patch
.unwrap_or_else(|| api_key_routing_group_id(current).map(ToOwned::to_owned));
if let Some(group_id) = group_id {
settings.insert(ROUTING_GROUP_ID.to_string(), Value::String(group_id));
}
Ok(Some(
(!settings.is_empty()).then_some(Value::Object(settings)),
))
}
pub(super) fn normalize_api_key_feature_settings_patch(
value: Option<Option<Value>>,
) -> Result<Option<Option<Value>>, String> {
let Some(value) = value else {
return Ok(None);
};
let Some(Value::Object(mut settings)) = normalize_feature_settings(value)? else {
return Ok(Some(None));
};
settings.remove(ROUTING_GROUP_ID);
settings.remove("routing_group_name");
Ok(Some(
(!settings.is_empty()).then_some(Value::Object(settings)),
))
}
pub(super) async fn routing_group_names(
state: &AppState,
needed: bool,
) -> BTreeMap<String, String> {
if !needed || !state.has_routing_group_data_reader() {
return BTreeMap::new();
}
match state.list_routing_groups().await {
Ok(groups) => groups
.into_iter()
.map(|group| (group.id, group.name))
.collect(),
Err(error) => {
tracing::warn!(?error, "API key routing group names unavailable");
BTreeMap::new()
}
}
}
pub(super) fn routing_group_payload_fields(
settings: Option<&Value>,
names: &BTreeMap<String, String>,
) -> Map<String, Value> {
let id = api_key_routing_group_id(settings);
Map::from_iter([
(ROUTING_GROUP_ID.to_string(), serde_json::json!(id)),
(
"routing_group_name".to_string(),
serde_json::json!(id.and_then(|id| names.get(id))),
),
])
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aether_data::repository::routing_profiles::InMemoryRoutingGroupRepository;
use aether_data_contracts::repository::routing_profiles::StoredRoutingGroup;
use serde_json::json;
use super::*;
fn group(id: &str, enabled: bool, visible: Value) -> StoredRoutingGroup {
StoredRoutingGroup {
id: id.to_string(),
name: format!("{id} current name"),
description: None,
enabled,
is_system_default: false,
sort_order: 0,
config_json: json!({"user_visible": visible}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
}
}
fn state() -> AppState {
let repository = Arc::new(InMemoryRoutingGroupRepository::seed(
[
group("public", true, json!(true)),
group("hidden", true, json!(false)),
group("disabled", false, json!(true)),
group("legacy", true, Value::Null),
group("malformed", true, json!("true")),
],
[],
[],
));
AppState::new().unwrap().with_data_state_for_tests(
crate::data::GatewayDataState::disabled()
.with_routing_group_repository_for_tests(repository),
)
}
#[tokio::test]
async fn only_enabled_user_visible_groups_can_be_newly_selected() {
let state = state();
for id in [
"",
" ",
"missing",
"hidden",
"disabled",
"legacy",
"malformed",
] {
assert_eq!(
validate_routing_group_patch(&state, None, Some(Some(id.to_string())))
.await
.unwrap_err()
.status(),
StatusCode::BAD_REQUEST,
"id={id}"
);
}
assert_eq!(
validate_routing_group_patch(&state, None, Some(Some(" public ".to_string())))
.await
.unwrap(),
Some(Some("public".to_string()))
);
let unavailable = AppState::new()
.unwrap()
.with_data_state_for_tests(crate::data::GatewayDataState::disabled());
assert_eq!(
validate_routing_group_patch(&unavailable, None, Some(Some("public".into())))
.await
.unwrap_err()
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[tokio::test]
async fn unchanged_or_cleared_choices_do_not_require_current_visibility_or_catalog_access() {
let state = AppState::new()
.unwrap()
.with_data_state_for_tests(crate::data::GatewayDataState::disabled());
let current = json!({"routing_group_id": "hidden"});
for patch in [None, Some(None), Some(Some("hidden".to_string()))] {
assert_eq!(
validate_routing_group_patch(&state, Some(&current), patch.clone())
.await
.unwrap(),
patch
);
}
}
#[test]
fn feature_patch_does_not_carry_a_pre_read_routing_selection() {
assert_eq!(
normalize_api_key_feature_settings_patch(None).unwrap(),
None
);
assert_eq!(
normalize_api_key_feature_settings_patch(Some(None)).unwrap(),
Some(None)
);
let patch = normalize_api_key_feature_settings_patch(Some(Some(json!({
"routing_group_id": "stale-or-forged",
"routing_group_name": "stale name",
"chat_pii_redaction": {"enabled": false},
}))))
.unwrap()
.flatten()
.unwrap();
assert!(patch.get("routing_group_id").is_none());
assert!(patch.get("routing_group_name").is_none());
assert_eq!(patch["chat_pii_redaction"]["enabled"], false);
}
#[test]
fn feature_updates_cannot_inject_replace_or_clear_a_routing_choice() {
let current =
json!({"routing_group_id": "saved", "chat_pii_redaction": {"enabled": false}});
let injection = json!({"routing_group_id": "hidden", "routing_group_name": "forged", "chat_pii_redaction": {"enabled": true}});
let created = merge_api_key_feature_settings(None, Some(Some(injection.clone())), None)
.unwrap()
.flatten()
.unwrap();
assert!(created.get("routing_group_id").is_none());
assert!(created.get("routing_group_name").is_none());
for feature_patch in [
Some(injection),
Some(json!({"routing_group_id": null})),
None,
] {
let updated = merge_api_key_feature_settings(Some(&current), Some(feature_patch), None)
.unwrap()
.flatten()
.unwrap();
assert_eq!(updated["routing_group_id"], "saved");
assert!(updated.get("routing_group_name").is_none());
}
let untouched = merge_api_key_feature_settings(Some(&current), None, None).unwrap();
assert_eq!(
untouched, None,
"name/rate/IP-only updates must leave settings untouched"
);
}
#[test]
fn validated_top_level_selection_and_clear_preserve_other_feature_settings() {
let current = json!({"routing_group_id": "saved", "chat_pii_redaction": {"enabled": false}, "another_setting": 7});
let selected =
merge_api_key_feature_settings(Some(&current), None, Some(Some("public".to_string())))
.unwrap()
.flatten()
.unwrap();
assert_eq!(selected["routing_group_id"], "public");
assert_eq!(selected["another_setting"], 7);
assert_eq!(selected["chat_pii_redaction"]["enabled"], false);
let cleared = merge_api_key_feature_settings(Some(&selected), None, Some(None))
.unwrap()
.flatten()
.unwrap();
assert!(cleared.get("routing_group_id").is_none());
assert_eq!(cleared["another_setting"], 7);
let replacement = merge_api_key_feature_settings(
None,
Some(Some(json!({"routing_group_id": "hidden"}))),
Some(Some("public".to_string())),
)
.unwrap()
.flatten()
.unwrap();
assert_eq!(replacement, json!({"routing_group_id": "public"}));
}
#[tokio::test]
async fn names_are_resolved_from_the_catalog_without_persisting_a_name_snapshot() {
let state = state();
let names = routing_group_names(&state, true).await;
let current = json!({"routing_group_id": "hidden", "routing_group_name": "stale"});
let fields = routing_group_payload_fields(Some(&current), &names);
assert_eq!(fields["routing_group_id"], "hidden");
assert_eq!(fields["routing_group_name"], "hidden current name");
let missing =
routing_group_payload_fields(Some(&json!({"routing_group_id": "deleted"})), &names);
assert_eq!(missing["routing_group_id"], "deleted");
assert_eq!(missing["routing_group_name"], Value::Null);
let default = routing_group_payload_fields(None, &names);
assert_eq!(default["routing_group_id"], Value::Null);
assert_eq!(default["routing_group_name"], Value::Null);
}
}
@@ -26,6 +26,14 @@ use super::{
const USERS_ME_API_KEY_WRITE_UNAVAILABLE_DETAIL: &str = "用户 API 密钥写入暂不可用";
#[path = "user_me_api_key_routing.rs"]
mod routing_selection;
use routing_selection::{
api_key_routing_group_id, deserialize_routing_group_patch, merge_api_key_feature_settings,
normalize_api_key_feature_settings_patch, routing_group_names, routing_group_payload_fields,
validate_routing_group_patch,
};
fn users_me_api_key_secret_response(mut response: Response<Body>) -> Response<Body> {
response.headers_mut().insert(
http::header::CACHE_CONTROL,
@@ -43,6 +51,8 @@ struct UsersMeCreateApiKeyRequest {
concurrent_limit: Option<i32>,
#[serde(default)]
feature_settings: Option<serde_json::Value>,
#[serde(default)]
routing_group_id: Option<String>,
#[serde(default, alias = "allowed_ips")]
ip_rules: Option<Vec<String>>,
}
@@ -57,6 +67,8 @@ struct UsersMeUpdateApiKeyRequest {
concurrent_limit: Option<i32>,
#[serde(default, deserialize_with = "deserialize_optional_json_patch")]
feature_settings: Option<Option<serde_json::Value>>,
#[serde(default, deserialize_with = "deserialize_routing_group_patch")]
routing_group_id: Option<Option<String>>,
#[serde(
default,
alias = "allowed_ips",
@@ -170,8 +182,9 @@ fn build_users_me_api_key_list_payload(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
is_locked: bool,
group_names: &BTreeMap<String, String>,
) -> serde_json::Value {
json!({
let mut payload = json!({
"id": record.api_key_id,
"name": record.name,
"key_display": users_me_masked_api_key_display(state, record),
@@ -187,15 +200,24 @@ fn build_users_me_api_key_list_payload(
"ip_rules": record.ip_rules,
"force_capabilities": record.force_capabilities,
"feature_settings": record.feature_settings,
})
});
payload
.as_object_mut()
.expect("API key payload is an object")
.extend(routing_group_payload_fields(
record.feature_settings.as_ref(),
group_names,
));
payload
}
fn build_users_me_api_key_detail_payload(
state: &AppState,
record: &aether_data::repository::auth::StoredAuthApiKeyExportRecord,
is_locked: bool,
group_names: &BTreeMap<String, String>,
) -> serde_json::Value {
json!({
let mut payload = json!({
"id": record.api_key_id,
"name": record.name,
"key_display": users_me_masked_api_key_display(state, record),
@@ -210,7 +232,15 @@ fn build_users_me_api_key_detail_payload(
"last_used_at": format_users_me_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
"expires_at": format_users_me_optional_unix_secs_iso8601(record.expires_at_unix_secs),
"created_at": format_users_me_optional_unix_secs_iso8601(record.created_at_unix_secs),
})
});
payload
.as_object_mut()
.expect("API key payload is an object")
.extend(routing_group_payload_fields(
record.feature_settings.as_ref(),
group_names,
));
payload
}
fn normalize_users_me_required_api_key_name(value: &str) -> Result<String, String> {
@@ -336,6 +366,13 @@ pub(super) async fn handle_users_me_api_keys_get(
};
records.retain(|record| !record.is_standalone);
records.sort_by(|left, right| left.api_key_id.cmp(&right.api_key_id));
let group_names = routing_group_names(
state,
records
.iter()
.any(|record| api_key_routing_group_id(record.feature_settings.as_ref()).is_some()),
)
.await;
let snapshot_ids = records
.iter()
@@ -367,7 +404,7 @@ pub(super) async fn handle_users_me_api_keys_get(
.get(&record.api_key_id)
.map(|snapshot| snapshot.api_key_is_locked)
.unwrap_or(false);
build_users_me_api_key_list_payload(state, record, is_locked)
build_users_me_api_key_list_payload(state, record, is_locked, &group_names)
})
.collect::<Vec<_>>(),
)
@@ -461,8 +498,16 @@ pub(super) async fn handle_users_me_api_key_detail_get(
}
};
let group_names = routing_group_names(
state,
api_key_routing_group_id(record.feature_settings.as_ref()).is_some(),
)
.await;
Json(build_users_me_api_key_detail_payload(
state, &record, is_locked,
state,
&record,
is_locked,
&group_names,
))
.into_response()
}
@@ -567,8 +612,17 @@ pub(super) async fn handle_users_me_api_key_create(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let feature_settings = match normalize_feature_settings(payload.feature_settings) {
Ok(value) => value,
let routing_group_patch =
match validate_routing_group_patch(state, None, Some(payload.routing_group_id)).await {
Ok(value) => value,
Err(response) => return response,
};
let feature_settings = match merge_api_key_feature_settings(
None,
Some(payload.feature_settings),
routing_group_patch,
) {
Ok(value) => value.flatten(),
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
@@ -631,26 +685,36 @@ pub(super) async fn handle_users_me_api_key_create(
return build_users_me_api_key_writer_unavailable_response();
};
users_me_api_key_secret_response(
Json(json!({
"id": created.api_key_id,
"name": created.name,
"key": plaintext_key,
"key_display": users_me_masked_api_key_display(state, &created),
"is_active": created.is_active,
"is_locked": false,
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"ip_rules": created.ip_rules,
"feature_settings": created.feature_settings,
"last_used_at": format_users_me_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_users_me_optional_unix_secs_iso8601(created.created_at_unix_secs),
"total_requests": created.total_requests,
"total_cost_usd": created.total_cost_usd,
"message": "API密钥创建成功",
}))
.into_response(),
let group_names = routing_group_names(
state,
api_key_routing_group_id(created.feature_settings.as_ref()).is_some(),
)
.await;
let mut payload = json!({
"id": created.api_key_id,
"name": created.name,
"key": plaintext_key,
"key_display": users_me_masked_api_key_display(state, &created),
"is_active": created.is_active,
"is_locked": false,
"rate_limit": created.rate_limit,
"concurrent_limit": created.concurrent_limit,
"ip_rules": created.ip_rules,
"feature_settings": created.feature_settings,
"last_used_at": format_users_me_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
"created_at": format_users_me_optional_unix_secs_iso8601(created.created_at_unix_secs),
"total_requests": created.total_requests,
"total_cost_usd": created.total_cost_usd,
"message": "API密钥创建成功",
});
payload
.as_object_mut()
.expect("API key payload is an object")
.extend(routing_group_payload_fields(
created.feature_settings.as_ref(),
&group_names,
));
users_me_api_key_secret_response(Json(payload).into_response())
}
pub(super) async fn handle_users_me_api_key_update(
@@ -715,14 +779,39 @@ pub(super) async fn handle_users_me_api_key_update(
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let feature_settings = match payload.feature_settings {
Some(value) => match normalize_feature_settings(value) {
Ok(value) => Some(value),
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
let current_features = if matches!(payload.routing_group_id.as_ref(), Some(Some(_))) {
match state
.read_auth_api_key_feature_settings(&auth.user.id, &snapshot.api_key_id, false)
.await
{
Ok(value) => value,
Err(error) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user API key feature settings lookup failed: {error:?}"),
false,
)
}
},
None => None,
}
} else {
None
};
let routing_group_patch = match validate_routing_group_patch(
state,
current_features.as_ref(),
payload.routing_group_id,
)
.await
{
Ok(value) => value,
Err(response) => return response,
};
let feature_settings = match normalize_api_key_feature_settings_patch(payload.feature_settings)
{
Ok(value) => value,
Err(detail) => {
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false)
}
};
let ip_rules = match payload.ip_rules {
Some(value) => match normalize_users_me_ip_rules(value) {
@@ -752,6 +841,11 @@ pub(super) async fn handle_users_me_api_key_update(
concurrent_limit_present,
ip_rules,
feature_settings,
routing_group_selection: Some(
aether_data::repository::auth::UpdateApiKeyRoutingGroupSelection {
group_id: routing_group_patch,
},
),
},
)
.await
@@ -768,8 +862,17 @@ pub(super) async fn handle_users_me_api_key_update(
return build_users_me_api_key_mutation_conflict_response();
};
let mut payload =
build_users_me_api_key_detail_payload(state, &updated, snapshot.api_key_is_locked);
let group_names = routing_group_names(
state,
api_key_routing_group_id(updated.feature_settings.as_ref()).is_some(),
)
.await;
let mut payload = build_users_me_api_key_detail_payload(
state,
&updated,
snapshot.api_key_is_locked,
&group_names,
);
payload["message"] = json!("API密钥已更新");
Json(payload).into_response()
}
@@ -1095,7 +1198,8 @@ mod tests {
use axum::{response::IntoResponse, Json};
use super::{
normalize_users_me_ip_rules, users_me_api_key_secret_response, UsersMeUpdateApiKeyRequest,
normalize_users_me_ip_rules, users_me_api_key_secret_response, UsersMeCreateApiKeyRequest,
UsersMeUpdateApiKeyRequest,
};
use serde_json::json;
@@ -1110,6 +1214,28 @@ mod tests {
);
}
#[test]
fn routing_group_selection_patch_distinguishes_missing_clear_and_valid_string() {
let unchanged: UsersMeUpdateApiKeyRequest =
serde_json::from_value(json!({"name": "renamed"})).unwrap();
assert_eq!(unchanged.routing_group_id, None);
let cleared: UsersMeUpdateApiKeyRequest =
serde_json::from_value(json!({"routing_group_id": null})).unwrap();
assert_eq!(cleared.routing_group_id, Some(None));
let selected: UsersMeUpdateApiKeyRequest =
serde_json::from_value(json!({"routing_group_id": "group-1"})).unwrap();
assert_eq!(selected.routing_group_id, Some(Some("group-1".to_string())));
let created: UsersMeCreateApiKeyRequest =
serde_json::from_value(json!({"name": "created", "routing_group_id": "group-1"}))
.unwrap();
assert_eq!(created.routing_group_id.as_deref(), Some("group-1"));
for invalid in [json!(true), json!(3), json!([]), json!({})] {
let request = json!({"name": "invalid", "routing_group_id": invalid});
assert!(serde_json::from_value::<UsersMeCreateApiKeyRequest>(request.clone()).is_err());
assert!(serde_json::from_value::<UsersMeUpdateApiKeyRequest>(request).is_err());
}
}
#[test]
fn normalize_ip_rules_trims_ip_and_cidr_values() {
let values = normalize_users_me_ip_rules(Some(vec![
@@ -17,12 +17,12 @@ use super::{
handle_users_me_model_capabilities_get, handle_users_me_model_capabilities_put,
handle_users_me_password_patch, handle_users_me_preferences_get,
handle_users_me_preferences_put, handle_users_me_providers_get, handle_users_me_referral_get,
handle_users_me_sessions_get, handle_users_me_update_session, handle_users_me_usage_active_get,
handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
handle_users_me_usage_interval_timeline_get, handle_users_me_vscodex_request,
users_me_api_key_capabilities_path_matches, users_me_api_key_detail_path_matches,
users_me_api_key_install_sessions_path_matches, users_me_api_key_providers_path_matches,
users_me_management_token_detail_path_matches,
handle_users_me_routing_groups_get, handle_users_me_sessions_get,
handle_users_me_update_session, handle_users_me_usage_active_get, handle_users_me_usage_get,
handle_users_me_usage_heatmap_get, handle_users_me_usage_interval_timeline_get,
handle_users_me_vscodex_request, users_me_api_key_capabilities_path_matches,
users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches,
users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches,
users_me_management_token_regenerate_path_matches,
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
@@ -231,6 +231,11 @@ pub(crate) async fn maybe_build_local_users_me_response(
Some("providers") if request_context.request_path == "/api/users/me/providers" => {
Some(handle_users_me_providers_get(state, request_context, headers).await)
}
Some("routing_groups")
if request_context.request_path == "/api/users/me/routing-groups" =>
{
Some(handle_users_me_routing_groups_get(state, request_context, headers).await)
}
Some("preferences") if request_context.request_path == "/api/users/me/preferences" => {
Some(handle_users_me_preferences_get(state, request_context, headers).await)
}
@@ -0,0 +1,58 @@
use aether_routing_core::RoutingGroupConfig;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use super::{
build_auth_error_response, resolve_authenticated_local_user, AppState,
GatewayPublicRequestContext,
};
use crate::routing::selection::routing_group_is_user_visible;
pub(super) async fn handle_users_me_routing_groups_get(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
if let Err(response) = resolve_authenticated_local_user(state, request_context, headers).await {
return response;
}
if !state.has_routing_group_data_reader() {
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"策略分组目录暂不可用",
false,
);
}
let groups = match state.list_routing_groups().await {
Ok(groups) => groups,
Err(error) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user routing group lookup failed: {error:?}"),
false,
)
}
};
let items = groups
.into_iter()
.filter(|group| group.enabled && routing_group_is_user_visible(group))
.filter_map(|group| {
let config = serde_json::from_value::<RoutingGroupConfig>(group.config_json).ok()?;
if !config.billing_multiplier.is_finite() || config.billing_multiplier < 0.0 {
return None;
}
Some(json!({
"id": group.id,
"name": group.name,
"billing_multiplier": config.billing_multiplier,
"is_default": group.is_system_default,
}))
})
.collect::<Vec<_>>();
Json(json!({"total": items.len(), "items": items})).into_response()
}
@@ -561,6 +561,8 @@ fn build_users_me_usage_record_payload(
let cache_read_price_per_1m = item.settlement_cache_read_price_per_1m();
let cache_creation_input_tokens = users_me_usage_cache_creation_tokens(item);
let rate_multiplier = item.settlement_rate_multiplier();
let billing_multiplier = item.billing_multiplier();
let billing_cost = item.billing_cost().map(|cost| round_to(cost, 6));
let client_is_stream = users_me_usage_client_is_stream(item);
let upstream_is_stream = users_me_usage_upstream_is_stream(item);
let mut payload = json!({
@@ -576,6 +578,8 @@ fn build_users_me_usage_record_payload(
"output_tokens": item.output_tokens,
"total_tokens": item.total_tokens,
"cost": round_to(item.total_cost_usd, 6),
"billing_multiplier": billing_multiplier,
"billing_cost": billing_cost,
"response_time_ms": item.response_time_ms,
"first_byte_time_ms": item.first_byte_time_ms,
"is_stream": item.is_stream,
@@ -614,6 +618,8 @@ fn build_users_me_usage_record_payload(
),
});
payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms"));
payload["routing_group_id"] = json!(item.routing_group_id());
payload["routing_group_name"] = json!(item.routing_group_name());
payload["end_to_end_first_byte_time_ms"] = json!(users_me_usage_metadata_u64(
item,
"end_to_end_first_byte_time_ms"
@@ -643,6 +649,8 @@ fn build_users_me_usage_record_payload(
fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_json::Value {
let cache_creation_input_tokens = users_me_usage_cache_creation_tokens(item);
let billing_multiplier = item.billing_multiplier();
let billing_cost = item.billing_cost().map(|cost| round_to(cost, 6));
let client_is_stream = users_me_usage_client_is_stream(item);
let upstream_is_stream = users_me_usage_upstream_is_stream(item);
let mut payload = json!({
@@ -659,6 +667,8 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
"cost": round_to(item.total_cost_usd, 6),
"actual_cost": round_to(item.actual_total_cost_usd, 6),
"rate_multiplier": item.settlement_rate_multiplier(),
"billing_multiplier": billing_multiplier,
"billing_cost": billing_cost,
"response_time_ms": item.response_time_ms,
"first_byte_time_ms": item.first_byte_time_ms,
"updated_at": unix_secs_to_rfc3339(item.updated_at_unix_secs),
@@ -685,6 +695,8 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
"response_model": item.provider_response_model(),
"has_fallback": item.has_fallback(),
});
payload["routing_group_id"] = json!(item.routing_group_id());
payload["routing_group_name"] = json!(item.routing_group_name());
payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms"));
payload["end_to_end_first_byte_time_ms"] = json!(users_me_usage_metadata_u64(
item,
@@ -1867,6 +1879,59 @@ mod tests {
assert_eq!(payload["cache_creation_ephemeral_1h_input_tokens"], 6);
}
#[test]
fn user_usage_payloads_preserve_routing_group_snapshot_and_precise_display_cost() {
for (metadata, multiplier, cost, group_name) in [
(None, 1.0, json!(0.0), serde_json::Value::Null),
(
Some(json!({"routing_group_billing_multiplier": 0.0})),
0.0,
json!(0.0),
serde_json::Value::Null,
),
(
Some(json!({
"routing_group_billing_multiplier": 2.5,
"routing_group_id": "group-1",
"routing_group_name": "请求时的分组",
"rate_multiplier": 0.5
})),
2.5,
json!(0.000004),
json!("请求时的分组"),
),
(
Some(json!({
"billing_multiplier_snapshot": {"version": 1, "factors": {"routing_group": 2.5, "user_group": 2.0}, "multiplier": 5.0},
"routing_group_billing_multiplier": 2.5,
"routing_group_id": "group-1",
"routing_group_name": "请求时的分组",
"rate_multiplier": 0.5
})),
5.0,
json!(0.000007),
json!("请求时的分组"),
),
] {
let item = StoredRequestUsageAudit {
total_cost_usd: 0.00000149,
actual_total_cost_usd: 0.0000002,
request_metadata: metadata,
..sample_usage("completed")
};
let record = build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false);
let active = build_users_me_usage_active_payload(&item);
for payload in [&record, &active] {
assert_eq!(payload["billing_multiplier"], multiplier);
assert_eq!(payload["billing_cost"], cost);
assert_eq!(payload["routing_group_name"], group_name);
assert_eq!(payload["cost"], 0.000001);
}
assert!(record.get("actual_cost").is_none());
assert!(record.get("rate_multiplier").is_none());
}
}
#[test]
fn user_usage_payloads_expose_response_model_separately_from_mapping() {
let item = StoredRequestUsageAudit {