1use axum::{
4 extract::{Extension, Path, State},
5 routing::{get, post},
6 Json, Router,
7};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::auth::Claims;
12use crate::error::{ApiError, ApiResult};
13use crate::sanitize::{sanitize_name, sanitize_prompt, sanitize_role};
14use crate::state::AppState;
15use vex_persist::AgentStore;
16#[derive(Debug, Serialize)]
20pub struct HealthResponse {
21 pub status: String,
22 pub version: String,
23 pub timestamp: chrono::DateTime<chrono::Utc>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub components: Option<ComponentHealth>,
26}
27
28#[derive(Debug, Serialize)]
30pub struct ComponentHealth {
31 pub database: ComponentStatus,
32 pub queue: ComponentStatus,
33}
34
35#[derive(Debug, Serialize)]
37pub struct ComponentStatus {
38 pub status: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub latency_ms: Option<u64>,
41}
42
43pub async fn health() -> Json<HealthResponse> {
45 Json(HealthResponse {
46 status: "healthy".to_string(),
47 version: env!("CARGO_PKG_VERSION").to_string(),
48 timestamp: chrono::Utc::now(),
49 components: None,
50 })
51}
52
53pub async fn health_detailed(State(state): State<AppState>) -> Json<HealthResponse> {
55 let start = std::time::Instant::now();
56
57 let db_healthy = state.db().is_healthy().await;
59 let db_latency = start.elapsed().as_millis() as u64;
60
61 let queue_status = ComponentStatus {
63 status: "healthy".to_string(),
64 latency_ms: Some(0),
65 };
66
67 let db_status = ComponentStatus {
68 status: if db_healthy { "healthy" } else { "unhealthy" }.to_string(),
69 latency_ms: Some(db_latency),
70 };
71
72 let overall_status = if db_healthy { "healthy" } else { "degraded" };
73
74 Json(HealthResponse {
75 status: overall_status.to_string(),
76 version: env!("CARGO_PKG_VERSION").to_string(),
77 timestamp: chrono::Utc::now(),
78 components: Some(ComponentHealth {
79 database: db_status,
80 queue: queue_status,
81 }),
82 })
83}
84
85#[derive(Debug, Deserialize)]
87pub struct CreateAgentRequest {
88 pub name: String,
89 pub role: String,
90 #[serde(default = "default_max_depth")]
91 pub max_depth: u8,
92 #[serde(default)]
93 pub spawn_shadow: bool,
94}
95
96fn default_max_depth() -> u8 {
97 3
98}
99
100#[derive(Debug, Serialize)]
102pub struct AgentResponse {
103 pub id: Uuid,
104 pub name: String,
105 pub role: String,
106 pub generation: u32,
107 pub fitness: f64,
108 pub created_at: chrono::DateTime<chrono::Utc>,
109}
110
111pub async fn create_agent(
113 Extension(claims): Extension<Claims>,
114 State(state): State<AppState>,
115 Json(req): Json<CreateAgentRequest>,
116) -> ApiResult<Json<AgentResponse>> {
117 if !claims.has_role("user") {
119 return Err(ApiError::Forbidden("Insufficient permissions".to_string()));
120 }
121
122 let name = sanitize_name(&req.name)
124 .map_err(|e| ApiError::Validation(format!("Invalid name: {}", e)))?;
125 let role = sanitize_role(&req.role)
126 .map_err(|e| ApiError::Validation(format!("Invalid role: {}", e)))?;
127
128 let config = vex_core::AgentConfig {
130 name: name.clone(),
131 role: role.clone(),
132 max_depth: req.max_depth,
133 spawn_shadow: req.spawn_shadow,
134 };
135 let agent = vex_core::Agent::new(config);
136
137 let prefix = format!("user:{}:agent:", claims.sub);
139 let store = AgentStore::with_prefix(state.db(), &prefix);
140
141 store
142 .save(&agent)
143 .await
144 .map_err(|e| ApiError::Internal(format!("Failed to save agent: {}", e)))?;
145
146 state.metrics().record_agent_created();
148
149 Ok(Json(AgentResponse {
150 id: agent.id,
151 name: req.name,
152 role: req.role,
153 generation: agent.generation,
154 fitness: agent.fitness,
155 created_at: chrono::Utc::now(),
156 }))
157}
158
159#[derive(Debug, Deserialize)]
161pub struct ExecuteRequest {
162 pub prompt: String,
163 #[serde(default)]
164 pub enable_adversarial: bool,
165 #[serde(default = "default_max_rounds")]
166 pub max_debate_rounds: u32,
167}
168
169fn default_max_rounds() -> u32 {
170 3
171}
172
173#[derive(Debug, Serialize)]
175pub struct ExecuteResponse {
176 pub agent_id: Uuid,
177 pub response: String,
178 pub verified: bool,
179 pub confidence: f64,
180 pub context_hash: String,
181 pub latency_ms: u64,
182}
183
184pub async fn execute_agent(
186 Extension(claims): Extension<Claims>,
187 State(state): State<AppState>,
188 Path(agent_id): Path<Uuid>,
189 Json(req): Json<ExecuteRequest>,
190) -> ApiResult<Json<ExecuteResponse>> {
191 let start = std::time::Instant::now();
192
193 let prompt = sanitize_prompt(&req.prompt)
195 .map_err(|e| ApiError::Validation(format!("Invalid prompt: {}", e)))?;
196
197 let prefix = format!("user:{}:agent:", claims.sub);
199 let store = AgentStore::with_prefix(state.db(), &prefix);
200
201 let exists = store
202 .exists(agent_id)
203 .await
204 .map_err(|e| ApiError::Internal(format!("Storage error: {}", e)))?;
205
206 if !exists {
207 return Err(ApiError::NotFound("Agent not found".to_string()));
208 }
209
210 let payload = serde_json::json!({
212 "agent_id": agent_id,
213 "prompt": prompt,
214 "config": {
215 "enable_adversarial": req.enable_adversarial,
216 "max_rounds": req.max_debate_rounds
217 },
218 "tenant_id": claims.sub
219 });
220
221 let pool = state.queue();
224
225 let backend = &pool.backend;
227
228 let job_id = backend
229 .enqueue("agent_execution", payload, None)
230 .await
231 .map_err(|e| ApiError::Internal(format!("Queue error: {}", e)))?;
232
233 state.metrics().record_llm_call(0, false); Ok(Json(ExecuteResponse {
237 agent_id,
238 response: format!("Job queued: {}", job_id),
239 verified: false,
240 confidence: 1.0,
241 context_hash: "pending".to_string(),
242 latency_ms: start.elapsed().as_millis() as u64,
243 }))
244}
245
246#[derive(Debug, Serialize)]
248pub struct MetricsResponse {
249 pub llm_calls: u64,
250 pub llm_errors: u64,
251 pub tokens_used: u64,
252 pub debates: u64,
253 pub agents_created: u64,
254 pub verifications: u64,
255 pub verification_rate: f64,
256 pub error_rate: f64,
257}
258
259pub async fn get_metrics(
261 Extension(claims): Extension<Claims>,
262 State(state): State<AppState>,
263) -> ApiResult<Json<MetricsResponse>> {
264 if !claims.has_role("admin") {
266 return Err(ApiError::Forbidden("Admin access required".to_string()));
267 }
268
269 let snapshot = state.metrics().snapshot();
270
271 Ok(Json(MetricsResponse {
272 llm_calls: snapshot.llm_calls,
273 llm_errors: snapshot.llm_errors,
274 tokens_used: snapshot.tokens_used,
275 debates: snapshot.debates,
276 agents_created: snapshot.agents_created,
277 verifications: snapshot.verifications,
278 verification_rate: state.metrics().verification_rate(),
279 error_rate: state.metrics().llm_error_rate(),
280 }))
281}
282
283pub async fn get_prometheus_metrics(
285 Extension(claims): Extension<Claims>,
286 State(state): State<AppState>,
287) -> ApiResult<String> {
288 if !claims.has_role("admin") {
290 return Err(ApiError::Forbidden("Admin access required".to_string()));
291 }
292
293 let snapshot = state.metrics().snapshot();
294 Ok(snapshot.to_prometheus())
295}
296
297pub fn api_router(state: AppState) -> Router {
299 Router::new()
300 .route("/health", get(health))
302 .route("/health/detailed", get(health_detailed))
303 .route("/api/v1/agents", post(create_agent))
305 .route("/api/v1/agents/{id}/execute", post(execute_agent))
306 .route("/api/v1/metrics", get(get_metrics))
308 .route("/metrics", get(get_prometheus_metrics))
309 .with_state(state)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_health_response() {
319 let health = HealthResponse {
320 status: "healthy".to_string(),
321 version: "0.1.0".to_string(),
322 timestamp: chrono::Utc::now(),
323 components: None,
324 };
325 assert_eq!(health.status, "healthy");
326 }
327}