mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
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:
@@ -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(¤t), 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(¤t), 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(¤t), 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(¤t), 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(¤t), &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 {
|
||||
|
||||
Reference in New Issue
Block a user