vex_api/
routes.rs

1//! API routes for VEX endpoints
2
3use 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// use vex_queue::QueueBackend; // Removed unused import
17
18/// Health check response
19#[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/// Component health status
29#[derive(Debug, Serialize)]
30pub struct ComponentHealth {
31    pub database: ComponentStatus,
32    pub queue: ComponentStatus,
33}
34
35/// Individual component status
36#[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
43/// Basic health check handler (lightweight)
44pub 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
53/// Detailed health check with database connectivity
54pub async fn health_detailed(State(state): State<AppState>) -> Json<HealthResponse> {
55    let start = std::time::Instant::now();
56
57    // Check database
58    let db_healthy = state.db().is_healthy().await;
59    let db_latency = start.elapsed().as_millis() as u64;
60
61    // Queue is always healthy (in-memory)
62    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/// Agent creation request
86#[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/// Agent response
101#[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
111/// Create agent handler
112pub async fn create_agent(
113    Extension(claims): Extension<Claims>,
114    State(state): State<AppState>,
115    Json(req): Json<CreateAgentRequest>,
116) -> ApiResult<Json<AgentResponse>> {
117    // Validate role access
118    if !claims.has_role("user") {
119        return Err(ApiError::Forbidden("Insufficient permissions".to_string()));
120    }
121
122    // Sanitize inputs
123    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    // Create agent with sanitized inputs
129    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    // Persist agent with tenant isolation
138    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    // Record metrics
147    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/// Execute agent request
160#[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/// Execute agent response
174#[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
184/// Execute agent handler
185pub 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    // Sanitize and validate prompt
194    let prompt = sanitize_prompt(&req.prompt)
195        .map_err(|e| ApiError::Validation(format!("Invalid prompt: {}", e)))?;
196
197    // Check ownership/existence
198    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    // Create job payload with sanitized prompt
211    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    // Enqueue job with explicit type checks
222    // Enqueue job via dynamic backend
223    let pool = state.queue();
224
225    // For dynamic dispatch, we access the backend. It's Arc<dyn QueueBackend>.
226    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    // Record metrics
234    state.metrics().record_llm_call(0, false); // Just counting requests for now
235
236    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/// Metrics response
247#[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
259/// Get metrics handler
260pub async fn get_metrics(
261    Extension(claims): Extension<Claims>,
262    State(state): State<AppState>,
263) -> ApiResult<Json<MetricsResponse>> {
264    // Only admins can view metrics
265    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
283/// Prometheus metrics handler
284pub async fn get_prometheus_metrics(
285    Extension(claims): Extension<Claims>,
286    State(state): State<AppState>,
287) -> ApiResult<String> {
288    // Only admins can view metrics
289    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
297/// Build the API router
298pub fn api_router(state: AppState) -> Router {
299    Router::new()
300        // Public endpoints
301        .route("/health", get(health))
302        .route("/health/detailed", get(health_detailed))
303        // Agent endpoints
304        .route("/api/v1/agents", post(create_agent))
305        .route("/api/v1/agents/{id}/execute", post(execute_agent))
306        // Admin endpoints
307        .route("/api/v1/metrics", get(get_metrics))
308        .route("/metrics", get(get_prometheus_metrics))
309        // State
310        .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}