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_async, sanitize_role};
14use crate::state::AppState;
15use utoipa::OpenApi;
16use vex_persist::AgentStore;
17
18#[derive(Debug, Serialize, utoipa::ToSchema)]
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, utoipa::ToSchema)]
30pub struct ComponentHealth {
31 pub database: ComponentStatus,
32 pub queue: ComponentStatus,
33}
34
35#[derive(Debug, Serialize, utoipa::ToSchema)]
37pub struct ComponentStatus {
38 pub status: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub latency_ms: Option<u64>,
41}
42
43#[utoipa::path(
45 get,
46 path = "/health",
47 responses(
48 (status = 200, description = "Basic health check", body = HealthResponse)
49 )
50)]
51pub async fn health() -> Json<HealthResponse> {
52 Json(HealthResponse {
53 status: "healthy".to_string(),
54 version: env!("CARGO_PKG_VERSION").to_string(),
55 timestamp: chrono::Utc::now(),
56 components: None,
57 })
58}
59
60#[utoipa::path(
62 get,
63 path = "/health/detailed",
64 responses(
65 (status = 200, description = "Detailed health check with component status", body = HealthResponse)
66 )
67)]
68pub async fn health_detailed(State(state): State<AppState>) -> Json<HealthResponse> {
69 let start = std::time::Instant::now();
70
71 let db_healthy = state.db().is_healthy().await;
73 let db_latency = start.elapsed().as_millis() as u64;
74
75 let queue_status = ComponentStatus {
77 status: "healthy".to_string(),
78 latency_ms: Some(0),
79 };
80
81 let db_status = ComponentStatus {
82 status: if db_healthy { "healthy" } else { "unhealthy" }.to_string(),
83 latency_ms: Some(db_latency),
84 };
85
86 let overall_status = if db_healthy { "healthy" } else { "degraded" };
87
88 Json(HealthResponse {
89 status: overall_status.to_string(),
90 version: env!("CARGO_PKG_VERSION").to_string(),
91 timestamp: chrono::Utc::now(),
92 components: Some(ComponentHealth {
93 database: db_status,
94 queue: queue_status,
95 }),
96 })
97}
98
99#[derive(Debug, Deserialize, utoipa::ToSchema)]
101pub struct CreateAgentRequest {
102 pub name: String,
103 pub role: String,
104 #[serde(default = "default_max_depth")]
105 pub max_depth: u8,
106 #[serde(default)]
107 pub spawn_shadow: bool,
108}
109
110fn default_max_depth() -> u8 {
111 3
112}
113
114#[derive(Debug, Serialize, utoipa::ToSchema)]
116pub struct AgentResponse {
117 pub id: Uuid,
118 pub name: String,
119 pub role: String,
120 pub generation: u32,
121 pub fitness: f64,
122 pub created_at: chrono::DateTime<chrono::Utc>,
123}
124
125#[utoipa::path(
127 post,
128 path = "/api/v1/agents",
129 request_body = CreateAgentRequest,
130 responses(
131 (status = 200, description = "Agent created successfully", body = AgentResponse),
132 (status = 403, description = "Insufficient permissions"),
133 (status = 400, description = "Invalid input")
134 ),
135 security(
136 ("jwt" = [])
137 )
138)]
139pub async fn create_agent(
140 Extension(claims): Extension<Claims>,
141 State(state): State<AppState>,
142 Json(req): Json<CreateAgentRequest>,
143) -> ApiResult<Json<AgentResponse>> {
144 if !claims.has_role("user") {
146 return Err(ApiError::Forbidden("Insufficient permissions".to_string()));
147 }
148
149 let name = sanitize_name(&req.name)
151 .map_err(|e| ApiError::Validation(format!("Invalid name: {}", e)))?;
152 let role = sanitize_role(&req.role)
153 .map_err(|e| ApiError::Validation(format!("Invalid role: {}", e)))?;
154
155 if req.max_depth > 10 {
157 return Err(ApiError::Validation(
158 "max_depth exceeds safety limit of 10".to_string(),
159 ));
160 }
161
162 let config = vex_core::AgentConfig {
164 name: name.clone(),
165 role: role.clone(),
166 max_depth: req.max_depth,
167 spawn_shadow: req.spawn_shadow,
168 };
169 let agent = vex_core::Agent::new(config);
170
171 let store = AgentStore::new(state.db());
173
174 store
175 .save(&claims.sub, &agent)
176 .await
177 .map_err(|e| ApiError::Internal(format!("Failed to save agent: {}", e)))?;
178
179 state.metrics().record_agent_created();
181
182 Ok(Json(AgentResponse {
183 id: agent.id,
184 name: req.name,
185 role: req.role,
186 generation: agent.generation,
187 fitness: agent.fitness,
188 created_at: chrono::Utc::now(),
189 }))
190}
191
192#[derive(Debug, Deserialize, utoipa::ToSchema)]
194pub struct ExecuteRequest {
195 pub prompt: String,
196 #[serde(default)]
197 pub enable_adversarial: bool,
198 #[serde(default = "default_max_rounds")]
199 pub max_debate_rounds: u32,
200}
201
202fn default_max_rounds() -> u32 {
203 3
204}
205
206#[derive(Debug, Serialize, utoipa::ToSchema)]
208pub struct ExecuteResponse {
209 pub agent_id: Uuid,
210 pub response: String,
211 pub verified: bool,
212 pub confidence: f64,
213 pub context_hash: String,
214 pub latency_ms: u64,
215}
216
217#[utoipa::path(
219 post,
220 path = "/api/v1/agents/{id}/execute",
221 params(
222 ("id" = Uuid, Path, description = "Agent ID")
223 ),
224 request_body = ExecuteRequest,
225 responses(
226 (status = 200, description = "Job queued successfully", body = ExecuteResponse),
227 (status = 404, description = "Agent not found")
228 ),
229 security(
230 ("jwt" = [])
231 )
232)]
233pub async fn execute_agent(
234 Extension(claims): Extension<Claims>,
235 State(state): State<AppState>,
236 Path(agent_id): Path<Uuid>,
237 Json(req): Json<ExecuteRequest>,
238) -> ApiResult<Json<ExecuteResponse>> {
239 let start = std::time::Instant::now();
240
241 let llm = state.llm();
243 let prompt = sanitize_prompt_async(&req.prompt, Some(&*llm))
244 .await
245 .map_err(|e| ApiError::Validation(format!("Invalid prompt: {}", e)))?;
246
247 let store = AgentStore::new(state.db());
249
250 let exists = store
251 .exists(&claims.sub, agent_id)
252 .await
253 .map_err(|e| ApiError::Internal(format!("Storage error: {}", e)))?;
254
255 if !exists {
256 return Err(ApiError::NotFound("Agent not found".to_string()));
257 }
258
259 let payload = serde_json::json!({
261 "agent_id": agent_id,
262 "prompt": prompt,
263 "config": {
264 "enable_adversarial": req.enable_adversarial,
265 "max_rounds": req.max_debate_rounds
266 },
267 "tenant_id": claims.sub
268 });
269
270 let pool = state.queue();
273
274 let backend = &pool.backend;
276
277 let job_id = backend
278 .enqueue(&claims.sub, "agent_execution", payload, None)
279 .await
280 .map_err(|e| ApiError::Internal(format!("Queue error: {}", e)))?;
281
282 state.metrics().record_llm_call(0, false); Ok(Json(ExecuteResponse {
286 agent_id,
287 response: format!("Job queued: {}", job_id),
288 verified: false,
289 confidence: 1.0,
290 context_hash: "pending".to_string(),
291 latency_ms: start.elapsed().as_millis() as u64,
292 }))
293}
294
295#[derive(Debug, Serialize, utoipa::ToSchema)]
297pub struct JobStatusResponse {
298 pub job_id: Uuid,
299 pub status: String,
300 pub result: Option<serde_json::Value>,
301 pub error: Option<String>,
302 pub queued_at: chrono::DateTime<chrono::Utc>,
303 pub attempts: u32,
304}
305
306#[utoipa::path(
308 get,
309 path = "/api/v1/jobs/{id}",
310 params(
311 ("id" = Uuid, Path, description = "Job ID returned from execute_agent")
312 ),
313 responses(
314 (status = 200, description = "Job status and result", body = JobStatusResponse),
315 (status = 404, description = "Job not found")
316 ),
317 security(
318 ("jwt" = [])
319 )
320)]
321pub async fn get_job_status(
322 Extension(claims): Extension<Claims>,
323 State(state): State<AppState>,
324 Path(job_id): Path<Uuid>,
325) -> ApiResult<Json<JobStatusResponse>> {
326 let pool = state.queue();
327 let backend = &pool.backend;
328
329 let tenant_id = claims.tenant_id.as_deref().unwrap_or(&claims.sub);
330
331 let job = backend
332 .get_job(tenant_id, job_id)
333 .await
334 .map_err(|_| ApiError::NotFound(format!("Job {} not found", job_id)))?;
335
336 let status_str = match job.status {
337 vex_queue::JobStatus::Pending => "pending",
338 vex_queue::JobStatus::Running => "running",
339 vex_queue::JobStatus::Completed => "completed",
340 vex_queue::JobStatus::Failed(_) => "failed",
341 vex_queue::JobStatus::DeadLetter => "dead_letter",
342 };
343
344 Ok(Json(JobStatusResponse {
345 job_id,
346 status: status_str.to_string(),
347 result: job.result,
348 error: job.last_error,
349 queued_at: job.created_at,
350 attempts: job.attempts,
351 }))
352}
353
354#[derive(Debug, Serialize, utoipa::ToSchema)]
356pub struct MetricsResponse {
357 pub llm_calls: u64,
358 pub llm_errors: u64,
359 pub tokens_used: u64,
360 pub debates: u64,
361 pub agents_created: u64,
362 pub verifications: u64,
363 pub verification_rate: f64,
364 pub error_rate: f64,
365}
366
367#[utoipa::path(
369 get,
370 path = "/api/v1/metrics",
371 responses(
372 (status = 200, description = "Current system metrics", body = MetricsResponse),
373 (status = 403, description = "Forbidden")
374 ),
375 security(
376 ("jwt" = [])
377 )
378)]
379pub async fn get_metrics(
380 Extension(claims): Extension<Claims>,
381 State(state): State<AppState>,
382) -> ApiResult<Json<MetricsResponse>> {
383 if !claims.has_role("admin") {
385 return Err(ApiError::Forbidden("Admin access required".to_string()));
386 }
387
388 let snapshot = state.metrics().snapshot();
389
390 Ok(Json(MetricsResponse {
391 llm_calls: snapshot.llm_calls,
392 llm_errors: snapshot.llm_errors,
393 tokens_used: snapshot.tokens_used,
394 debates: snapshot.debates,
395 agents_created: snapshot.agents_created,
396 verifications: snapshot.verifications,
397 verification_rate: state.metrics().verification_rate(),
398 error_rate: state.metrics().llm_error_rate(),
399 }))
400}
401
402#[derive(Debug, Serialize, utoipa::ToSchema)]
404pub struct RoutingStatsResponse {
405 pub summary: vex_router::ObservabilitySummary,
406 pub savings: vex_router::SavingsReport,
407}
408
409#[utoipa::path(
411 get,
412 path = "/api/v1/routing/stats",
413 responses(
414 (status = 200, description = "Current routing statistics and cost savings", body = RoutingStatsResponse),
415 (status = 404, description = "Router not enabled"),
416 (status = 403, description = "Forbidden")
417 ),
418 security(
419 ("jwt" = [])
420 )
421)]
422pub async fn get_routing_stats(
423 Extension(claims): Extension<Claims>,
424 State(state): State<AppState>,
425) -> ApiResult<Json<RoutingStatsResponse>> {
426 if !claims.has_role("admin") {
428 return Err(ApiError::Forbidden("Admin access required".to_string()));
429 }
430
431 let router = state
432 .router()
433 .ok_or_else(|| ApiError::NotFound("Router not enabled".to_string()))?;
434 let obs = router.observability();
435
436 Ok(Json(RoutingStatsResponse {
437 summary: obs.get_summary(),
438 savings: obs.get_savings(),
439 }))
440}
441
442#[derive(Debug, Deserialize, utoipa::ToSchema)]
444pub struct UpdateRoutingConfigRequest {
445 pub strategy: String,
446 pub cache_enabled: bool,
447 pub compression_level: String,
448}
449
450#[utoipa::path(
452 put,
453 path = "/api/v1/routing/config",
454 request_body = UpdateRoutingConfigRequest,
455 responses(
456 (status = 200, description = "Routing configuration updated successfully"),
457 (status = 404, description = "Router not enabled"),
458 (status = 400, description = "Invalid configuration"),
459 (status = 403, description = "Forbidden")
460 ),
461 security(
462 ("jwt" = [])
463 )
464)]
465pub async fn update_routing_config(
466 Extension(claims): Extension<Claims>,
467 State(state): State<AppState>,
468 Json(req): Json<UpdateRoutingConfigRequest>,
469) -> ApiResult<Json<HealthResponse>> {
470 if !claims.has_role("admin") {
472 return Err(ApiError::Forbidden("Admin access required".to_string()));
473 }
474
475 let _router = state
476 .router()
477 .ok_or_else(|| ApiError::NotFound("Router not enabled".to_string()))?;
478
479 Ok(Json(HealthResponse {
483 status: format!("Routing strategy updated to {}", req.strategy),
484 version: env!("CARGO_PKG_VERSION").to_string(),
485 timestamp: chrono::Utc::now(),
486 components: None,
487 }))
488}
489
490#[utoipa::path(
492 get,
493 path = "/metrics",
494 responses(
495 (status = 200, description = "Prometheus formatted metrics", body = String)
496 )
497)]
498pub async fn get_prometheus_metrics(
499 Extension(claims): Extension<Claims>,
500 State(state): State<AppState>,
501) -> ApiResult<String> {
502 if !claims.has_role("admin") {
504 return Err(ApiError::Forbidden("Admin access required".to_string()));
505 }
506
507 let snapshot = state.metrics().snapshot();
508 Ok(snapshot.to_prometheus())
509}
510
511#[derive(OpenApi)]
512#[openapi(
513 paths(
514 health,
515 health_detailed,
516 create_agent,
517 execute_agent,
518 get_job_status,
519 get_metrics,
520 get_prometheus_metrics,
521 get_routing_stats,
522 update_routing_config,
523 crate::a2a::handler::agent_card_handler,
524 crate::a2a::handler::create_task_handler,
525 crate::a2a::handler::get_task_handler,
526 ),
527 components(
528 schemas(
529 HealthResponse, ComponentHealth, ComponentStatus,
530 CreateAgentRequest, AgentResponse,
531 ExecuteRequest, ExecuteResponse,
532 JobStatusResponse,
533 MetricsResponse,
534 RoutingStatsResponse,
535 UpdateRoutingConfigRequest,
536 crate::a2a::agent_card::AgentCard,
537 crate::a2a::agent_card::AuthConfig,
538 crate::a2a::agent_card::Skill,
539 crate::a2a::task::TaskRequest,
540 crate::a2a::task::TaskResponse,
541 crate::a2a::task::TaskStatus,
542 )
543 ),
544 modifiers(&SecurityAddon)
545)]
546pub struct ApiDoc;
547
548struct SecurityAddon;
549
550impl utoipa::Modify for SecurityAddon {
551 fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
552 if let Some(components) = openapi.components.as_mut() {
553 components.add_security_scheme(
554 "jwt",
555 utoipa::openapi::security::SecurityScheme::Http(
556 utoipa::openapi::security::HttpBuilder::new()
557 .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
558 .bearer_format("JWT")
559 .build(),
560 ),
561 )
562 }
563 }
564}
565
566pub fn api_router(state: AppState) -> Router {
568 use utoipa_swagger_ui::SwaggerUi;
569
570 Router::new()
571 .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
573 .merge(crate::a2a::handler::a2a_routes().with_state(state.a2a_state()))
575 .route("/health", get(health))
577 .route("/health/detailed", get(health_detailed))
578 .route("/api/v1/agents", post(create_agent))
580 .route("/api/v1/agents/{id}/execute", post(execute_agent))
581 .route("/api/v1/jobs/{id}", get(get_job_status))
583 .route("/api/v1/metrics", get(get_metrics))
585 .route("/api/v1/routing/stats", get(get_routing_stats))
586 .route(
587 "/api/v1/routing/config",
588 axum::routing::put(update_routing_config),
589 )
590 .route("/metrics", get(get_prometheus_metrics))
591 .with_state(state)
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 #[test]
600 fn test_health_response() {
601 let health = HealthResponse {
602 status: "healthy".to_string(),
603 version: "0.1.0".to_string(),
604 timestamp: chrono::Utc::now(),
605 components: None,
606 };
607 assert_eq!(health.status, "healthy");
608 }
609}