cargo fmt: format all Rust source files
All checks were successful
CI — P1 Route (Rust) / test (push) Successful in 6m35s
All checks were successful
CI — P1 Route (Rust) / test (push) Successful in 6m35s
This commit is contained in:
@@ -2,7 +2,7 @@ use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, put, delete},
|
||||
routing::{delete, get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -23,7 +23,10 @@ pub fn create_api_router(state: Arc<ApiState>) -> Router {
|
||||
Router::new()
|
||||
// Dashboard analytics
|
||||
.route("/api/v1/analytics/summary", get(get_analytics_summary))
|
||||
.route("/api/v1/analytics/timeseries", get(get_analytics_timeseries))
|
||||
.route(
|
||||
"/api/v1/analytics/timeseries",
|
||||
get(get_analytics_timeseries),
|
||||
)
|
||||
.route("/api/v1/analytics/models", get(get_model_breakdown))
|
||||
// Routing rules
|
||||
.route("/api/v1/rules", get(list_rules).post(create_rule))
|
||||
@@ -32,7 +35,10 @@ pub fn create_api_router(state: Arc<ApiState>) -> Router {
|
||||
.route("/api/v1/keys", get(list_keys).post(create_key))
|
||||
.route("/api/v1/keys/:id", delete(revoke_key))
|
||||
// Provider configs
|
||||
.route("/api/v1/providers", get(list_providers).post(upsert_provider))
|
||||
.route(
|
||||
"/api/v1/providers",
|
||||
get(list_providers).post(upsert_provider),
|
||||
)
|
||||
// Org settings
|
||||
.route("/api/v1/org", get(get_org))
|
||||
// Health
|
||||
@@ -40,7 +46,9 @@ pub fn create_api_router(state: Arc<ApiState>) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health() -> &'static str { "ok" }
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
// --- Analytics Endpoints ---
|
||||
|
||||
@@ -76,7 +84,9 @@ async fn get_analytics_summary(
|
||||
Query(range): Query<TimeRange>,
|
||||
) -> Result<Json<AnalyticsSummary>, ApiError> {
|
||||
let auth = state.auth.authenticate(&headers).await?;
|
||||
let _from = range.from.unwrap_or_else(|| "now() - interval '7 days'".to_string());
|
||||
let _from = range
|
||||
.from
|
||||
.unwrap_or_else(|| "now() - interval '7 days'".to_string());
|
||||
|
||||
let row = sqlx::query_as::<_, (i64, f64, f64, f64, i32, i32, i64, i64, i64)>(
|
||||
"SELECT
|
||||
@@ -90,14 +100,18 @@ async fn get_analytics_summary(
|
||||
COUNT(*) FILTER (WHERE strategy = 'cheapest'),
|
||||
COUNT(*) FILTER (WHERE strategy = 'cascading')
|
||||
FROM request_events
|
||||
WHERE org_id = $1 AND time >= now() - interval '7 days'"
|
||||
WHERE org_id = $1 AND time >= now() - interval '7 days'",
|
||||
)
|
||||
.bind(auth.org_id.parse::<Uuid>().unwrap_or_default())
|
||||
.fetch_one(&state.ts_pool)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let savings_pct = if row.1 > 0.0 { (row.3 / row.1) * 100.0 } else { 0.0 };
|
||||
let savings_pct = if row.1 > 0.0 {
|
||||
(row.3 / row.1) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(Json(AnalyticsSummary {
|
||||
total_requests: row.0,
|
||||
@@ -131,7 +145,11 @@ async fn get_analytics_timeseries(
|
||||
let auth = state.auth.authenticate(&headers).await?;
|
||||
let interval = range.interval.unwrap_or_else(|| "hour".to_string());
|
||||
|
||||
let view = if interval == "day" { "request_events_daily" } else { "request_events_hourly" };
|
||||
let view = if interval == "day" {
|
||||
"request_events_daily"
|
||||
} else {
|
||||
"request_events_hourly"
|
||||
};
|
||||
|
||||
let rows = sqlx::query_as::<_, (chrono::DateTime<chrono::Utc>, i64, f64, i32)>(
|
||||
&format!(
|
||||
@@ -147,12 +165,16 @@ async fn get_analytics_timeseries(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(rows.iter().map(|r| TimeseriesPoint {
|
||||
bucket: r.0.to_rfc3339(),
|
||||
request_count: r.1,
|
||||
cost_saved: r.2,
|
||||
avg_latency_ms: r.3,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| TimeseriesPoint {
|
||||
bucket: r.0.to_rfc3339(),
|
||||
request_count: r.1,
|
||||
cost_saved: r.2,
|
||||
avg_latency_ms: r.3,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -181,12 +203,16 @@ async fn get_model_breakdown(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(rows.iter().map(|r| ModelBreakdown {
|
||||
model: r.0.clone(),
|
||||
request_count: r.1,
|
||||
total_tokens: r.2,
|
||||
total_cost: r.3,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| ModelBreakdown {
|
||||
model: r.0.clone(),
|
||||
request_count: r.1,
|
||||
total_tokens: r.2,
|
||||
total_cost: r.3,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
// --- Routing Rules CRUD ---
|
||||
@@ -222,20 +248,24 @@ async fn list_rules(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(rows.iter().map(|r| RoutingRuleDto {
|
||||
id: Some(r.0),
|
||||
priority: r.1,
|
||||
name: r.2.clone(),
|
||||
match_model: r.3.clone(),
|
||||
match_feature: r.4.clone(),
|
||||
match_team: r.5.clone(),
|
||||
match_complexity: r.6.clone(),
|
||||
strategy: r.7.clone(),
|
||||
target_model: r.8.clone(),
|
||||
target_provider: r.9.clone(),
|
||||
fallback_models: r.10.clone(),
|
||||
enabled: r.11,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| RoutingRuleDto {
|
||||
id: Some(r.0),
|
||||
priority: r.1,
|
||||
name: r.2.clone(),
|
||||
match_model: r.3.clone(),
|
||||
match_feature: r.4.clone(),
|
||||
match_team: r.5.clone(),
|
||||
match_complexity: r.6.clone(),
|
||||
strategy: r.7.clone(),
|
||||
target_model: r.8.clone(),
|
||||
target_provider: r.9.clone(),
|
||||
fallback_models: r.10.clone(),
|
||||
enabled: r.11,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_rule(
|
||||
@@ -361,23 +391,37 @@ async fn list_keys(
|
||||
) -> Result<Json<Vec<ApiKeyDto>>, ApiError> {
|
||||
let auth = state.auth.authenticate(&headers).await?;
|
||||
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, Vec<String>, Option<chrono::DateTime<chrono::Utc>>, chrono::DateTime<chrono::Utc>)>(
|
||||
let rows = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
Uuid,
|
||||
String,
|
||||
String,
|
||||
Vec<String>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
),
|
||||
>(
|
||||
"SELECT id, name, key_prefix, scopes, last_used_at, created_at
|
||||
FROM api_keys WHERE org_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC"
|
||||
FROM api_keys WHERE org_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(auth.org_id.parse::<Uuid>().unwrap_or_default())
|
||||
.fetch_all(&state.pg_pool)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(rows.iter().map(|r| ApiKeyDto {
|
||||
id: r.0,
|
||||
name: r.1.clone(),
|
||||
key_prefix: r.2.clone(),
|
||||
scopes: r.3.clone(),
|
||||
last_used_at: r.4,
|
||||
created_at: r.5,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| ApiKeyDto {
|
||||
id: r.0,
|
||||
name: r.1.clone(),
|
||||
key_prefix: r.2.clone(),
|
||||
scopes: r.3.clone(),
|
||||
last_used_at: r.4,
|
||||
created_at: r.5,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_key(
|
||||
@@ -409,11 +453,14 @@ async fn create_key(
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ApiKeyCreated {
|
||||
id,
|
||||
key: raw_key,
|
||||
name: req.name,
|
||||
})))
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(ApiKeyCreated {
|
||||
id,
|
||||
key: raw_key,
|
||||
name: req.name,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn revoke_key(
|
||||
@@ -456,19 +503,23 @@ async fn list_providers(
|
||||
let auth = state.auth.authenticate(&headers).await?;
|
||||
|
||||
let rows = sqlx::query_as::<_, (String, Option<String>, bool)>(
|
||||
"SELECT provider, base_url, is_default FROM provider_configs WHERE org_id = $1"
|
||||
"SELECT provider, base_url, is_default FROM provider_configs WHERE org_id = $1",
|
||||
)
|
||||
.bind(auth.org_id.parse::<Uuid>().unwrap_or_default())
|
||||
.fetch_all(&state.pg_pool)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(rows.iter().map(|r| ProviderDto {
|
||||
provider: r.0.clone(),
|
||||
base_url: r.1.clone(),
|
||||
is_default: r.2,
|
||||
has_key: true,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|r| ProviderDto {
|
||||
provider: r.0.clone(),
|
||||
base_url: r.1.clone(),
|
||||
is_default: r.2,
|
||||
has_key: true,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -488,13 +539,12 @@ async fn upsert_provider(
|
||||
require_role(&auth, Role::Owner)?;
|
||||
|
||||
// Encrypt API key with AES-256-GCM before storing
|
||||
let encryption_key = std::env::var("PROVIDER_KEY_ENCRYPTION_KEY")
|
||||
.unwrap_or_else(|_| "0".repeat(64)); // 32-byte hex key
|
||||
let key_bytes = hex::decode(&encryption_key)
|
||||
.unwrap_or_else(|_| vec![0u8; 32]);
|
||||
let encryption_key =
|
||||
std::env::var("PROVIDER_KEY_ENCRYPTION_KEY").unwrap_or_else(|_| "0".repeat(64)); // 32-byte hex key
|
||||
let key_bytes = hex::decode(&encryption_key).unwrap_or_else(|_| vec![0u8; 32]);
|
||||
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
|
||||
use aes_gcm::Nonce;
|
||||
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
|
||||
.map_err(|e| ApiError::Internal(format!("Encryption key error: {}", e)))?;
|
||||
@@ -502,7 +552,8 @@ async fn upsert_provider(
|
||||
getrandom::getrandom(&mut nonce_bytes)
|
||||
.map_err(|e| ApiError::Internal(format!("RNG error: {}", e)))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher.encrypt(nonce, req.api_key.as_bytes())
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, req.api_key.as_bytes())
|
||||
.map_err(|e| ApiError::Internal(format!("Encryption error: {}", e)))?;
|
||||
|
||||
// Store as nonce || ciphertext
|
||||
@@ -544,7 +595,7 @@ async fn get_org(
|
||||
let auth = state.auth.authenticate(&headers).await?;
|
||||
|
||||
let row = sqlx::query_as::<_, (Uuid, String, String, String)>(
|
||||
"SELECT id, name, slug, tier FROM organizations WHERE id = $1"
|
||||
"SELECT id, name, slug, tier FROM organizations WHERE id = $1",
|
||||
)
|
||||
.bind(auth.org_id.parse::<Uuid>().unwrap_or_default())
|
||||
.fetch_optional(&state.pg_pool)
|
||||
@@ -606,7 +657,10 @@ impl IntoResponse for ApiError {
|
||||
ApiError::AuthError(_) => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
ApiError::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
ApiError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()),
|
||||
ApiError::Internal(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal error".to_string(),
|
||||
),
|
||||
};
|
||||
(status, serde_json::json!({"error": msg}).to_string()).into_response()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user