mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Isolate standalone key wallet handling
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ExecutionRuntimeAuthContext {
|
||||
pub user_id: String,
|
||||
@@ -10,4 +14,6 @@ pub struct ExecutionRuntimeAuthContext {
|
||||
pub api_key_name: Option<String>,
|
||||
pub balance_remaining: Option<f64>,
|
||||
pub access_allowed: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub api_key_is_standalone: bool,
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
api_key_name: None,
|
||||
balance_remaining: Some(12.5),
|
||||
access_allowed: true,
|
||||
api_key_is_standalone: false,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ pub struct UsageSettlementInput {
|
||||
pub request_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub api_key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub api_key_is_standalone: bool,
|
||||
pub provider_id: Option<String>,
|
||||
pub status: String,
|
||||
pub billing_status: String,
|
||||
@@ -71,6 +73,7 @@ mod tests {
|
||||
request_id: "".to_string(),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
api_key_is_standalone: false,
|
||||
provider_id: None,
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
|
||||
@@ -115,6 +115,9 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
|
||||
.map(|wallet| wallet.id.clone())
|
||||
})
|
||||
.or_else(|| {
|
||||
if input.api_key_is_standalone {
|
||||
return None;
|
||||
}
|
||||
input.user_id.as_deref().and_then(|user_id| {
|
||||
wallets
|
||||
.values()
|
||||
@@ -215,6 +218,25 @@ mod tests {
|
||||
.expect("wallet should build")
|
||||
}
|
||||
|
||||
fn sample_user_wallet(wallet_id: &str, user_id: &str) -> StoredWalletSnapshot {
|
||||
StoredWalletSnapshot::new(
|
||||
wallet_id.to_string(),
|
||||
Some(user_id.to_string()),
|
||||
None,
|
||||
10.0,
|
||||
2.0,
|
||||
"finite".to_string(),
|
||||
"USD".to_string(),
|
||||
"active".to_string(),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
100,
|
||||
)
|
||||
.expect("wallet should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settles_usage_against_wallet_and_provider_quota() {
|
||||
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
|
||||
@@ -223,6 +245,7 @@ mod tests {
|
||||
request_id: "req-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("key-1".to_string()),
|
||||
api_key_is_standalone: false,
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
@@ -240,6 +263,60 @@ mod tests {
|
||||
assert_eq!(settlement.provider_monthly_used_usd, Some(1.5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_key_settlement_falls_back_to_user_wallet() {
|
||||
let repository =
|
||||
InMemorySettlementRepository::seed(vec![sample_user_wallet("wallet-user-1", "user-1")]);
|
||||
let settlement = repository
|
||||
.settle_usage(UsageSettlementInput {
|
||||
request_id: "req-user-wallet".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("normal-key-without-wallet".to_string()),
|
||||
api_key_is_standalone: false,
|
||||
provider_id: None,
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 3.0,
|
||||
actual_total_cost_usd: 1.5,
|
||||
finalized_at_unix_secs: Some(200),
|
||||
})
|
||||
.await
|
||||
.expect("settlement should succeed")
|
||||
.expect("settlement should exist");
|
||||
|
||||
assert_eq!(settlement.wallet_id.as_deref(), Some("wallet-user-1"));
|
||||
assert_eq!(settlement.wallet_balance_before, Some(12.0));
|
||||
assert_eq!(settlement.wallet_balance_after, Some(9.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_key_settlement_never_falls_back_to_owner_wallet() {
|
||||
let repository = InMemorySettlementRepository::seed(vec![sample_user_wallet(
|
||||
"wallet-admin-owner",
|
||||
"admin-owner",
|
||||
)]);
|
||||
let settlement = repository
|
||||
.settle_usage(UsageSettlementInput {
|
||||
request_id: "req-standalone-no-key-wallet".to_string(),
|
||||
user_id: Some("admin-owner".to_string()),
|
||||
api_key_id: Some("standalone-key-without-wallet".to_string()),
|
||||
api_key_is_standalone: true,
|
||||
provider_id: None,
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
total_cost_usd: 3.0,
|
||||
actual_total_cost_usd: 1.5,
|
||||
finalized_at_unix_secs: Some(200),
|
||||
})
|
||||
.await
|
||||
.expect("settlement should succeed")
|
||||
.expect("settlement should exist");
|
||||
|
||||
assert_eq!(settlement.wallet_id, None);
|
||||
assert_eq!(settlement.wallet_balance_before, None);
|
||||
assert_eq!(settlement.wallet_balance_after, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_stored_snapshot_when_usage_is_already_finalized() {
|
||||
let repository = InMemorySettlementRepository::seed(vec![sample_wallet()]);
|
||||
@@ -248,6 +325,7 @@ mod tests {
|
||||
request_id: "req-2".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("key-1".to_string()),
|
||||
api_key_is_standalone: false,
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
@@ -264,6 +342,7 @@ mod tests {
|
||||
request_id: "req-2".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("key-1".to_string()),
|
||||
api_key_is_standalone: false,
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
billing_status: "settled".to_string(),
|
||||
|
||||
@@ -247,11 +247,31 @@ impl SettlementWriteRepository for SqlxSettlementRepository {
|
||||
};
|
||||
|
||||
if final_billing_status == "settled" {
|
||||
let wallet_row = if let Some(api_key_id) = input
|
||||
let api_key_id = input
|
||||
.api_key_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
.filter(|value| !value.is_empty());
|
||||
let api_key_is_standalone = if input.api_key_is_standalone {
|
||||
true
|
||||
} else if let Some(api_key_id) = api_key_id {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT is_standalone
|
||||
FROM api_keys
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(api_key_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let wallet_row = if let Some(api_key_id) = api_key_id {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -275,11 +295,12 @@ LIMIT 1
|
||||
|
||||
let wallet_row = if wallet_row.is_some() {
|
||||
wallet_row
|
||||
} else if let Some(user_id) =
|
||||
input.user_id.as_deref().filter(|value| !value.is_empty())
|
||||
{
|
||||
sqlx::query(
|
||||
r#"
|
||||
} else if !api_key_is_standalone {
|
||||
if let Some(user_id) =
|
||||
input.user_id.as_deref().filter(|value| !value.is_empty())
|
||||
{
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
CAST(balance AS DOUBLE PRECISION) AS balance,
|
||||
@@ -289,12 +310,15 @@ FROM wallets
|
||||
WHERE user_id = $1
|
||||
FOR UPDATE
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_postgres_err()?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -417,4 +441,11 @@ mod tests {
|
||||
let source = include_str!("sql.rs");
|
||||
assert!(!source.contains("UPDATE \"usage\"\nSET\n wallet_id = $2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settlement_sql_blocks_standalone_key_owner_wallet_fallback() {
|
||||
let source = include_str!("sql.rs");
|
||||
assert!(source.contains("SELECT is_standalone"));
|
||||
assert!(source.contains("} else if !api_key_is_standalone {"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,6 +992,7 @@ mod tests {
|
||||
request_id: "".to_string(),
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
api_key_is_standalone: false,
|
||||
provider_id: None,
|
||||
status: "completed".to_string(),
|
||||
billing_status: "pending".to_string(),
|
||||
|
||||
@@ -69,6 +69,7 @@ fn copy_allowed_metadata_fields(source: &Map<String, Value>, target: &mut Map<St
|
||||
copy_non_empty_string(source, target, "trace_id");
|
||||
copy_bool(source, target, "client_requested_stream");
|
||||
copy_bool(source, target, "upstream_is_stream");
|
||||
copy_bool(source, target, "api_key_is_standalone");
|
||||
copy_number(source, target, "provider_request_body_base64_bytes");
|
||||
copy_number(source, target, "provider_response_body_base64_bytes");
|
||||
copy_number(source, target, "client_response_body_base64_bytes");
|
||||
@@ -97,6 +98,7 @@ fn move_allowed_metadata_fields(mut source: Map<String, Value>, target: &mut Map
|
||||
remove_non_empty_string(&mut source, target, "trace_id");
|
||||
remove_bool(&mut source, target, "client_requested_stream");
|
||||
remove_bool(&mut source, target, "upstream_is_stream");
|
||||
remove_bool(&mut source, target, "api_key_is_standalone");
|
||||
remove_number(&mut source, target, "provider_request_body_base64_bytes");
|
||||
remove_number(&mut source, target, "provider_response_body_base64_bytes");
|
||||
remove_number(&mut source, target, "client_response_body_base64_bytes");
|
||||
@@ -384,6 +386,7 @@ mod tests {
|
||||
"trace_id": "trace-1",
|
||||
"client_requested_stream": false,
|
||||
"upstream_is_stream": true,
|
||||
"api_key_is_standalone": true,
|
||||
"provider_request_body_base64_bytes": 512,
|
||||
"provider_response_body_base64_bytes": 1024,
|
||||
"client_response_body_base64_bytes": 2048,
|
||||
@@ -414,6 +417,7 @@ mod tests {
|
||||
"trace_id": "trace-1",
|
||||
"client_requested_stream": false,
|
||||
"upstream_is_stream": true,
|
||||
"api_key_is_standalone": true,
|
||||
"provider_request_body_base64_bytes": 512,
|
||||
"provider_response_body_base64_bytes": 1024,
|
||||
"client_response_body_base64_bytes": 2048,
|
||||
@@ -472,6 +476,7 @@ mod tests {
|
||||
"candidate_index": 0,
|
||||
"client_requested_stream": false,
|
||||
"upstream_is_stream": true,
|
||||
"api_key_is_standalone": true,
|
||||
"provider_id": "provider-1",
|
||||
"model_id": "model-1",
|
||||
"global_model_id": "global-model-1",
|
||||
@@ -489,6 +494,7 @@ mod tests {
|
||||
json!({
|
||||
"client_requested_stream": false,
|
||||
"upstream_is_stream": true,
|
||||
"api_key_is_standalone": true,
|
||||
"model_id": "model-1",
|
||||
"global_model_id": "global-model-1",
|
||||
"global_model_name": "gpt-5",
|
||||
|
||||
@@ -31,6 +31,7 @@ pub async fn settle_usage_if_needed(
|
||||
request_id: usage.request_id.clone(),
|
||||
user_id: usage.user_id.clone(),
|
||||
api_key_id: usage.api_key_id.clone(),
|
||||
api_key_is_standalone: usage_api_key_is_standalone(usage),
|
||||
provider_id: usage.provider_id.clone(),
|
||||
status: usage.status.clone(),
|
||||
billing_status: usage.billing_status.clone(),
|
||||
@@ -42,6 +43,15 @@ pub async fn settle_usage_if_needed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn usage_api_key_is_standalone(usage: &StoredRequestUsageAudit) -> bool {
|
||||
usage
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("api_key_is_standalone"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn finite_cost(value: f64) -> Result<f64, DataLayerError> {
|
||||
if value.is_finite() {
|
||||
Ok(value)
|
||||
@@ -60,6 +70,7 @@ mod tests {
|
||||
use aether_data_contracts::repository::settlement::UsageSettlementInput;
|
||||
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestSettlementWriter {
|
||||
@@ -150,6 +161,25 @@ mod tests {
|
||||
assert_eq!(inputs[0].finalized_at_unix_secs, Some(200));
|
||||
assert_eq!(inputs[0].total_cost_usd, 1.25);
|
||||
assert_eq!(inputs[0].actual_total_cost_usd, 0.75);
|
||||
assert!(!inputs[0].api_key_is_standalone);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propagates_standalone_key_flag_from_usage_metadata() {
|
||||
let writer = TestSettlementWriter {
|
||||
has_writer: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut usage = sample_usage();
|
||||
usage.request_metadata = Some(json!({ "api_key_is_standalone": true }));
|
||||
|
||||
settle_usage_if_needed(&writer, &usage)
|
||||
.await
|
||||
.expect("settlement should succeed");
|
||||
|
||||
let inputs = writer.inputs.lock().expect("settlement inputs lock");
|
||||
assert_eq!(inputs.len(), 1);
|
||||
assert!(inputs[0].api_key_is_standalone);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1543,6 +1543,12 @@ fn build_runtime_request_metadata_seed_from_parts(
|
||||
Value::Bool(upstream_is_stream),
|
||||
);
|
||||
}
|
||||
if let Some(api_key_is_standalone) = context_bool(context, "api_key_is_standalone") {
|
||||
metadata.insert(
|
||||
"api_key_is_standalone".to_string(),
|
||||
Value::Bool(api_key_is_standalone),
|
||||
);
|
||||
}
|
||||
let provider_source_bytes = provider_request_body_base64.and_then(decoded_base64_len_hint);
|
||||
append_runtime_body_capture_metadata(
|
||||
&mut metadata,
|
||||
@@ -2498,6 +2504,47 @@ mod tests {
|
||||
assert_eq!(record.request_metadata, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_usage_record_preserves_standalone_key_metadata() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-pending-standalone-1".to_string(),
|
||||
candidate_id: None,
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://example.com/v1/responses".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5.4"})),
|
||||
stream: false,
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
tls_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
|
||||
let record = build_pending_usage_record(
|
||||
&plan,
|
||||
Some(&json!({
|
||||
"api_key_is_standalone": true
|
||||
})),
|
||||
1_700_000_000,
|
||||
)
|
||||
.expect("pending usage should build");
|
||||
|
||||
assert_eq!(
|
||||
record.request_metadata,
|
||||
Some(json!({
|
||||
"api_key_is_standalone": true
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_usage_records_stay_lightweight_by_default() {
|
||||
let plan = ExecutionPlan {
|
||||
|
||||
Reference in New Issue
Block a user