From b0cb2335dc2ce7ffae8eccc6dcc22a39301333b9 Mon Sep 17 00:00:00 2001 From: "firat.sertgoz" Date: Wed, 6 May 2026 15:43:31 +0300 Subject: [PATCH] fix(libsql): parse scientific notation cost aggregates (#3296) --- src/channels/web/tests/multi_tenant.rs | 44 ++++++++++++++++++++++++++ src/db/libsql/users.rs | 26 ++++++++------- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs index 2e809eefea..82b9a8f3f4 100644 --- a/src/channels/web/tests/multi_tenant.rs +++ b/src/channels/web/tests/multi_tenant.rs @@ -1091,6 +1091,50 @@ mod admin_api_contracts { assert_rfc3339(user.last_active_at.as_deref().unwrap()); } + #[tokio::test] + async fn test_admin_user_list_handles_tiny_costs_from_libsql() { + let (db, _dir) = test_db().await; + db.create_user(&test_user( + "carol", + "Carol", + Some("carol@example.com"), + "active", + "member", + serde_json::json!({}), + )) + .await + .unwrap(); + let job_id = db.create_system_job("carol", "test").await.unwrap(); + db.record_llm_call(&crate::history::LlmCallRecord { + job_id: Some(job_id), + conversation_id: None, + provider: "test", + model: "tiny-cost-model", + input_tokens: 1, + output_tokens: 1, + cost: rust_decimal::Decimal::from_str_exact("0.000075").unwrap(), + purpose: Some("test"), + }) + .await + .unwrap(); + + let state = build_state(Some(db), None); + let app = admin_router(state, two_user_auth()); + + let req = Request::builder() + .uri("/api/admin/users") + .header("Authorization", "Bearer tok-alice") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: AdminUserListResponse = parse_json(resp).await; + + let user = body.users.iter().find(|u| u.id == "carol").unwrap(); + assert_eq!(user.job_count, 1); + assert_eq!(user.total_cost, "0.000075"); + } + #[tokio::test] async fn test_admin_user_detail_response_contract() { let (db, _dir) = test_db().await; diff --git a/src/db/libsql/users.rs b/src/db/libsql/users.rs index 936c1c8d59..8ad1df565f 100644 --- a/src/db/libsql/users.rs +++ b/src/db/libsql/users.rs @@ -45,6 +45,17 @@ fn row_to_api_token(row: &libsql::Row) -> Result }) } +fn parse_libsql_decimal_text( + value: &str, + field_name: &str, +) -> Result { + rust_decimal::Decimal::from_str_exact(value) + .or_else(|_| rust_decimal::Decimal::from_scientific(value)) + .map_err(|e| { + DatabaseError::Query(format!("invalid {} value '{}': {}", field_name, value, e)) + }) +} + pub(crate) async fn seed_initial_assistant_thread( conn: &libsql::Connection, user_id: &str, @@ -658,9 +669,7 @@ impl UserStore for LibSqlBackend { .map_err(|e| DatabaseError::Query(e.to_string()))? { let cost_str = get_text(&row, 5); - let total_cost = rust_decimal::Decimal::from_str_exact(&cost_str).map_err(|e| { - DatabaseError::Query(format!("invalid cost value '{}': {}", cost_str, e)) - })?; + let total_cost = parse_libsql_decimal_text(&cost_str, "cost")?; stats.push(crate::db::UserUsageStats { user_id: get_text(&row, 0), model: get_text(&row, 1), @@ -836,9 +845,7 @@ impl UserStore for LibSqlBackend { .map_err(|e| DatabaseError::Query(e.to_string()))? { let cost_str = get_text(&row, 2); - let total_cost = rust_decimal::Decimal::from_str_exact(&cost_str).map_err(|e| { - DatabaseError::Query(format!("invalid cost value '{}': {}", cost_str, e)) - })?; + let total_cost = parse_libsql_decimal_text(&cost_str, "cost")?; stats.push(crate::db::UserSummaryStats { user_id: get_text(&row, 0), job_count: row @@ -896,12 +903,7 @@ impl UserStore for LibSqlBackend { })?; let usage_cost_str = get_text(&row, 8); - let usage_cost = rust_decimal::Decimal::from_str_exact(&usage_cost_str).map_err(|e| { - DatabaseError::Query(format!( - "invalid usage_cost value '{}': {}", - usage_cost_str, e - )) - })?; + let usage_cost = parse_libsql_decimal_text(&usage_cost_str, "usage_cost")?; Ok(AdminUsageSummary { total_users: row