Skip to main content

spec_ai/spec_ai_api/api/
handlers.rs

1/// API request handlers
2use crate::spec_ai_api::agent::builder::AgentBuilder;
3use crate::spec_ai_api::agent::core::AgentCore;
4use crate::spec_ai_api::api::auth::{AuthService, TokenRequest, TokenResponse};
5use crate::spec_ai_api::api::mesh::{MeshRegistry, MeshState};
6use crate::spec_ai_api::api::models::*;
7use crate::spec_ai_api::config::{AgentRegistry, AppConfig, SafetyConfig};
8use crate::spec_ai_api::persistence::Persistence;
9use crate::spec_ai_api::tools::ToolRegistry;
10use async_stream::stream;
11use axum::{
12    extract::{Json, State},
13    http::StatusCode,
14    response::{
15        IntoResponse, Response,
16        sse::{Event, Sse},
17    },
18};
19use futures::StreamExt;
20use serde_json::json;
21use std::convert::Infallible;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::{Instant, SystemTime, UNIX_EPOCH};
25use toak_rs::{JsonDatabaseGenerator, JsonDatabaseOptions, SemanticSearch};
26use tokio::sync::RwLock;
27
28const DEFAULT_PAGE_SIZE: usize = 10;
29const MAX_PAGE_SIZE: usize = 25;
30const MAX_TOTAL_RESULTS: usize = 100;
31
32/// Shared application state
33#[derive(Clone)]
34pub struct AppState {
35    pub persistence: Persistence,
36    pub agent_registry: Arc<AgentRegistry>,
37    pub tool_registry: Arc<ToolRegistry>,
38    pub config: AppConfig,
39    pub start_time: Instant,
40    pub mesh_registry: MeshRegistry,
41    pub auth_service: Arc<AuthService>,
42}
43
44impl AppState {
45    pub fn new(
46        persistence: Persistence,
47        agent_registry: Arc<AgentRegistry>,
48        tool_registry: Arc<ToolRegistry>,
49        config: AppConfig,
50    ) -> Self {
51        // Initialize auth service from config
52        let auth_service = AuthService::new(
53            config.auth.credentials_file.as_deref(),
54            config.auth.token_secret.as_deref(),
55            Some(config.auth.token_expiry_secs),
56            config.auth.enabled,
57        )
58        .unwrap_or_else(|e| {
59            tracing::warn!("Failed to initialize auth service: {}. Auth disabled.", e);
60            AuthService::disabled()
61        });
62
63        Self {
64            persistence: persistence.clone(),
65            agent_registry,
66            tool_registry,
67            config,
68            start_time: Instant::now(),
69            mesh_registry: MeshRegistry::with_persistence(persistence),
70            auth_service: Arc::new(auth_service),
71        }
72    }
73}
74
75impl MeshState for AppState {
76    fn mesh_registry(&self) -> &MeshRegistry {
77        &self.mesh_registry
78    }
79}
80
81/// Health check endpoint
82pub async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
83    let uptime = state.start_time.elapsed().as_secs();
84    let active_sessions = match state.persistence.list_sessions() {
85        Ok(sessions) => sessions.len(),
86        Err(e) => {
87            tracing::warn!("Failed to count active sessions: {}", e);
88            0
89        }
90    };
91
92    let response = HealthResponse {
93        status: "healthy".to_string(),
94        version: env!("CARGO_PKG_VERSION").to_string(),
95        uptime_seconds: uptime,
96        active_sessions,
97    };
98
99    Json(response)
100}
101
102/// List available agents
103pub async fn list_agents(State(state): State<AppState>) -> impl IntoResponse {
104    let agent_names = state.agent_registry.list();
105    let mut agent_infos = Vec::new();
106
107    for name in agent_names {
108        if let Some(profile) = state.agent_registry.get(&name) {
109            agent_infos.push(AgentInfo {
110                id: name,
111                description: profile.prompt.unwrap_or_default(),
112                allowed_tools: profile.allowed_tools.unwrap_or_default(),
113                denied_tools: profile.denied_tools.unwrap_or_default(),
114            });
115        }
116    }
117
118    Json(AgentListResponse {
119        agents: agent_infos,
120    })
121    .into_response()
122}
123
124/// Query endpoint - process a message and return response
125pub async fn query(State(state): State<AppState>, Json(request): Json<QueryRequest>) -> Response {
126    // If streaming requested, delegate to streaming handler
127    if request.stream {
128        return (
129            StatusCode::BAD_REQUEST,
130            Json(ErrorResponse::new(
131                "invalid_request",
132                "Streaming not supported on /query endpoint. Use /stream instead.",
133            )),
134        )
135            .into_response();
136    }
137
138    // Determine which agent to use
139    let safety_override = request.safety.clone();
140    let max_tokens_override = request.max_tokens;
141    let agent_name = request.agent.unwrap_or_else(|| "default".to_string());
142
143    // Get or create session ID
144    let session_id = request
145        .session_id
146        .unwrap_or_else(|| format!("api_{}", uuid_v4()));
147
148    // Create agent instance
149    let agent_result = create_agent(
150        &state,
151        &agent_name,
152        &session_id,
153        request.temperature,
154        max_tokens_override,
155        safety_override,
156    )
157    .await;
158
159    let mut agent = match agent_result {
160        Ok(agent) => agent,
161        Err(e) => {
162            return (
163                StatusCode::BAD_REQUEST,
164                Json(ErrorResponse::new("agent_error", e.to_string())),
165            )
166                .into_response();
167        }
168    };
169
170    // Process the message
171    let start = Instant::now();
172
173    match agent.run_step(&request.message).await {
174        Ok(output) => {
175            let processing_time = start.elapsed().as_millis() as u64;
176            let tool_calls: Vec<ToolCallInfo> = output
177                .tool_invocations
178                .iter()
179                .map(|inv| ToolCallInfo {
180                    name: inv.name.clone(),
181                    arguments: inv.arguments.clone(),
182                    success: inv.success,
183                    output: inv.output.clone(),
184                    error: inv.error.clone(),
185                })
186                .collect();
187
188            let response = QueryResponse {
189                response: output.response,
190                session_id,
191                agent: agent_name,
192                tool_calls,
193                token_usage: output.token_usage.clone(),
194                metadata: ResponseMetadata {
195                    timestamp: current_timestamp(),
196                    model: state.config.model.provider.clone(),
197                    processing_time_ms: processing_time,
198                    token_usage: output.token_usage.clone(),
199                    run_id: output.run_id,
200                    safety: output.safety.clone(),
201                },
202            };
203
204            Json(response).into_response()
205        }
206        Err(e) => (
207            StatusCode::INTERNAL_SERVER_ERROR,
208            Json(ErrorResponse::new("execution_error", e.to_string())),
209        )
210            .into_response(),
211    }
212}
213
214/// Streaming query endpoint
215pub async fn stream_query(
216    State(state): State<AppState>,
217    Json(request): Json<QueryRequest>,
218) -> Response {
219    let safety_override = request.safety.clone();
220    let max_tokens_override = request.max_tokens;
221    let agent_name = request.agent.unwrap_or_else(|| "default".to_string());
222    let session_id = request
223        .session_id
224        .unwrap_or_else(|| format!("api_{}", uuid_v4()));
225
226    // Create agent
227    let agent_result = create_agent(
228        &state,
229        &agent_name,
230        &session_id,
231        request.temperature,
232        max_tokens_override,
233        safety_override,
234    )
235    .await;
236
237    let agent = match agent_result {
238        Ok(agent) => agent,
239        Err(e) => {
240            return (
241                StatusCode::BAD_REQUEST,
242                Json(ErrorResponse::new("agent_error", e.to_string())),
243            )
244                .into_response();
245        }
246    };
247
248    // Create SSE stream
249    let agent = Arc::new(RwLock::new(agent));
250    let message = request.message.clone();
251    let session_id_clone = session_id.clone();
252    let agent_name_clone = agent_name.clone();
253    let model_id = state.config.model.provider.clone();
254
255    let sse_stream = stream! {
256        yield StreamChunk::Start {
257            session_id: session_id_clone.clone(),
258            agent: agent_name_clone.clone(),
259        };
260
261        let start = Instant::now();
262        let mut agent_lock = agent.write().await;
263
264        match agent_lock.run_step(&message).await {
265            Ok(output) => {
266                yield StreamChunk::Content { text: output.response.clone() };
267
268                for invocation in output.tool_invocations {
269                    yield StreamChunk::ToolCall {
270                        name: invocation.name.clone(),
271                        arguments: invocation.arguments.clone(),
272                    };
273                    yield StreamChunk::ToolResult {
274                        name: invocation.name.clone(),
275                        result: json!({
276                            "success": invocation.success,
277                            "output": invocation.output,
278                            "error": invocation.error,
279                        }),
280                    };
281                }
282
283                yield StreamChunk::End {
284                    metadata: ResponseMetadata {
285                        timestamp: current_timestamp(),
286                        model: model_id.clone(),
287                        processing_time_ms: start.elapsed().as_millis() as u64,
288                        token_usage: output.token_usage.clone(),
289                        run_id: output.run_id,
290                        safety: output.safety.clone(),
291                    },
292                };
293            }
294            Err(e) => {
295                yield StreamChunk::Error {
296                    message: e.to_string(),
297                };
298            }
299        }
300    };
301
302    Sse::new(sse_stream.map(|chunk| {
303        let json = serde_json::to_string(&chunk).unwrap();
304        Ok::<_, Infallible>(Event::default().data(json))
305    }))
306    .into_response()
307}
308
309/// Helper: Create agent instance
310async fn create_agent(
311    state: &AppState,
312    agent_name: &str,
313    session_id: &str,
314    _temperature: Option<f32>,
315    max_tokens: Option<usize>,
316    safety_override: Option<SafetyConfig>,
317) -> anyhow::Result<AgentCore> {
318    // Get the agent profile
319    let profile = state
320        .agent_registry
321        .get(agent_name)
322        .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", agent_name))?;
323
324    // Build the agent using the builder with config
325    let mut agent = AgentBuilder::new()
326        .with_profile(profile)
327        .with_config(state.config.clone())
328        .with_session_id(session_id)
329        .with_agent_name(agent_name.to_string())
330        .with_tool_registry(state.tool_registry.clone())
331        .with_persistence(state.persistence.clone())
332        .build()?;
333
334    let mut safety_config = state.config.safety.clone();
335    if let Some(override_config) = safety_override {
336        safety_config = override_config;
337    } else if let Some(max_tokens) = max_tokens {
338        safety_config.max_output_tokens_per_call = max_tokens.min(u32::MAX as usize) as u32;
339    }
340    safety_config.validate()?;
341    agent.set_safety_config(safety_config);
342
343    Ok(agent)
344}
345
346/// Helper: Generate UUID v4
347fn uuid_v4() -> String {
348    let rng = std::collections::hash_map::RandomState::new();
349    let hash = std::hash::BuildHasher::hash_one(&rng, SystemTime::now());
350    format!("{:x}", hash)
351}
352
353/// Helper: Get current timestamp
354fn current_timestamp() -> String {
355    let now = SystemTime::now()
356        .duration_since(UNIX_EPOCH)
357        .unwrap()
358        .as_secs();
359    chrono::DateTime::from_timestamp(now as i64, 0)
360        .unwrap()
361        .to_rfc3339()
362}
363
364/// Token generation endpoint - exchange username/password for bearer token
365pub async fn generate_token(
366    State(state): State<AppState>,
367    Json(request): Json<TokenRequest>,
368) -> Response {
369    // Check if auth is enabled
370    if !state.auth_service.is_enabled() {
371        return (
372            StatusCode::SERVICE_UNAVAILABLE,
373            Json(ErrorResponse::new(
374                "auth_disabled",
375                "Authentication is not enabled on this server",
376            )),
377        )
378            .into_response();
379    }
380
381    // Verify credentials
382    if !state
383        .auth_service
384        .verify_password(&request.username, &request.password)
385    {
386        return (
387            StatusCode::UNAUTHORIZED,
388            Json(ErrorResponse::new(
389                "invalid_credentials",
390                "Invalid username or password",
391            )),
392        )
393            .into_response();
394    }
395
396    // Generate token
397    match state.auth_service.generate_token(&request.username) {
398        Ok(token) => {
399            let response = TokenResponse {
400                token,
401                token_type: "Bearer".to_string(),
402                expires_in: state.config.auth.token_expiry_secs,
403            };
404            Json(response).into_response()
405        }
406        Err(e) => {
407            tracing::error!("Failed to generate token: {}", e);
408            (
409                StatusCode::INTERNAL_SERVER_ERROR,
410                Json(ErrorResponse::new(
411                    "token_error",
412                    "Failed to generate token",
413                )),
414            )
415                .into_response()
416        }
417    }
418}
419
420/// Password hash generation endpoint - utility for creating password hashes
421/// This endpoint can be used to generate hashes for the credentials file
422pub async fn hash_password(
423    State(state): State<AppState>,
424    Json(body): Json<serde_json::Value>,
425) -> Response {
426    // Only allow if auth is enabled (to prevent abuse)
427    if !state.auth_service.is_enabled() {
428        return (
429            StatusCode::SERVICE_UNAVAILABLE,
430            Json(ErrorResponse::new(
431                "auth_disabled",
432                "Authentication is not enabled on this server",
433            )),
434        )
435            .into_response();
436    }
437
438    let Some(password) = body.get("password").and_then(|v| v.as_str()) else {
439        return (
440            StatusCode::BAD_REQUEST,
441            Json(ErrorResponse::new(
442                "invalid_request",
443                "Missing 'password' field in request body",
444            )),
445        )
446            .into_response();
447    };
448
449    match AuthService::hash_password(password) {
450        Ok(hash) => Json(json!({ "password_hash": hash })).into_response(),
451        Err(e) => {
452            tracing::error!("Failed to hash password: {}", e);
453            (
454                StatusCode::INTERNAL_SERVER_ERROR,
455                Json(ErrorResponse::new("hash_error", "Failed to hash password")),
456            )
457                .into_response()
458        }
459    }
460}
461
462/// Semantic code search endpoint
463pub async fn search(Json(request): Json<SearchRequest>) -> Response {
464    // Validate query
465    if request.query.trim().is_empty() {
466        return (
467            StatusCode::BAD_REQUEST,
468            Json(ErrorResponse::new(
469                "invalid_request",
470                "Query cannot be empty",
471            )),
472        )
473            .into_response();
474    }
475
476    // Resolve root path
477    let root = request
478        .root
479        .as_ref()
480        .map(PathBuf::from)
481        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
482
483    if !root.exists() {
484        return (
485            StatusCode::BAD_REQUEST,
486            Json(ErrorResponse::new(
487                "invalid_root",
488                format!("Search root {} does not exist", root.display()),
489            )),
490        )
491            .into_response();
492    }
493
494    // Calculate pagination parameters
495    let page_size = request
496        .page_size
497        .unwrap_or(DEFAULT_PAGE_SIZE)
498        .clamp(1, MAX_PAGE_SIZE);
499    let page = request.page;
500    let offset = page * page_size;
501
502    // Ensure embeddings exist
503    let embeddings_path = match ensure_embeddings(&root, request.refresh).await {
504        Ok(path) => path,
505        Err(e) => {
506            tracing::error!("Failed to generate embeddings: {}", e);
507            return (
508                StatusCode::INTERNAL_SERVER_ERROR,
509                Json(ErrorResponse::new(
510                    "embeddings_error",
511                    format!("Failed to generate embeddings: {}", e),
512                )),
513            )
514                .into_response();
515        }
516    };
517
518    // Load searcher and run search
519    let mut searcher = match SemanticSearch::new(&embeddings_path) {
520        Ok(s) => s,
521        Err(e) => {
522            tracing::error!("Failed to load embeddings: {}", e);
523            return (
524                StatusCode::INTERNAL_SERVER_ERROR,
525                Json(ErrorResponse::new(
526                    "search_error",
527                    format!("Failed to load embeddings database: {}", e),
528                )),
529            )
530                .into_response();
531        }
532    };
533
534    // Fetch enough results to determine total and extract current page
535    let fetch_count = MAX_TOTAL_RESULTS.min(offset + page_size);
536    let hits = match searcher.search(&request.query, fetch_count) {
537        Ok(h) => h,
538        Err(e) => {
539            tracing::error!("Search failed: {}", e);
540            return (
541                StatusCode::INTERNAL_SERVER_ERROR,
542                Json(ErrorResponse::new(
543                    "search_error",
544                    format!("Search failed: {}", e),
545                )),
546            )
547                .into_response();
548        }
549    };
550
551    let total_results = hits.len();
552    let total_pages = total_results.div_ceil(page_size);
553
554    // Extract current page results
555    let page_results: Vec<SearchResult> = hits
556        .into_iter()
557        .skip(offset)
558        .take(page_size)
559        .map(|hit| {
560            let mut snippet = hit.content;
561            if snippet.len() > 480 {
562                snippet.truncate(480);
563                snippet.push_str("...[truncated]");
564            }
565            SearchResult {
566                path: hit.file_path,
567                similarity: hit.similarity,
568                snippet,
569            }
570        })
571        .collect();
572
573    let response = SearchResponse {
574        query: request.query,
575        root: root.display().to_string(),
576        page,
577        page_size,
578        total_results,
579        total_pages,
580        results: page_results,
581    };
582
583    Json(response).into_response()
584}
585
586/// Helper: Ensure embeddings database exists
587async fn ensure_embeddings(root: &Path, refresh: bool) -> anyhow::Result<PathBuf> {
588    let embeddings_path = root.join(".spec-ai").join("code_search_embeddings.json");
589
590    if embeddings_path.exists() && !refresh {
591        return Ok(embeddings_path);
592    }
593
594    if let Some(parent) = embeddings_path.parent() {
595        std::fs::create_dir_all(parent)?;
596    }
597
598    let options = JsonDatabaseOptions {
599        dir: root.to_path_buf(),
600        output_file_path: embeddings_path.clone(),
601        verbose: false,
602        chunker_config: Default::default(),
603        max_concurrent_files: 4,
604        ..Default::default()
605    };
606
607    let generator = JsonDatabaseGenerator::new(options)?;
608    generator.generate_database().await?;
609
610    Ok(embeddings_path)
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn test_uuid_generation() {
619        let uuid1 = uuid_v4();
620        let uuid2 = uuid_v4();
621
622        assert!(!uuid1.is_empty());
623        assert!(!uuid2.is_empty());
624        // UUIDs should be different (probabilistically)
625        // We won't assert this as it could theoretically fail
626    }
627
628    #[test]
629    fn test_timestamp_format() {
630        let ts = current_timestamp();
631        assert!(ts.contains('T'));
632        assert!(ts.contains('Z') || ts.contains('+'));
633    }
634}